feat(ptt): code-side initial split — transmit_active / capability badge / sanitizer

Implements the gen2 v0.9.3 doc baseline's first slice of code work:

  * SRS-201: split the audio engine's `ptt` AtomicBool into the
    authoritative `transmit_active` flag. The legacy `set_ptt` /
    `ptt` accessors are retained as `#[doc(hidden)]` thin wrappers
    so the existing bridge command and the existing Flutter
    hold-to-talk UI keep compiling.
  * SAD-075 / SDD-089 acknowledged at the type level: only
    `AudioEngine::set_transmit_active` (or its legacy alias)
    mutates the flag; the encoder feed reads it once per outbound
    frame and never writes.
  * SDD-082: new `chanora_audio::ptt` module ships the
    `PttCapabilityLevel` enum (`L0Focused`, `L1GlobalShortcut`,
    `L2GlobalHoldToTalk`, `L3GlobalWithMouseButtons`,
    `L4DeviceAware` reserved) with a stable `as_str` mapping and
    an `is_global` classifier.
  * SDD-087: `PttBackendDescriptor::focused()` constant value for
    the universal Focused-PTT fallback. The struct shape carries
    only privacy-safe fields (`level`, `backend_id`,
    `bound_input_class`) — a key code cannot fit through this
    surface by construction (DEC-027).
  * SAD-077 / SDD-090: `RedactingLogLayer` now hosts the
    `PttBanCheckVisitor` and the `PTT_BANNED_FIELDS` constant
    (`key_code`, `scan_code`, `virtual_key`, `vk`, `keysym`,
    `keysym_string`, `key_sequence`, `key_press_history`,
    `key_timing`). Any record whose field set names a banned key
    is dropped before reaching the in-memory log sink or the
    user-initiated diagnostic export. The check is structural and
    runs ahead of formatting / redaction.
  * `SessionEvent::PttCapability` carries the diagnostics-safe
    descriptor through the broadcast event stream;
    `chanora_core::ChanoraSession::start_audio` publishes the
    Focused-PTT descriptor when the audio engine starts (SRS-196
    / SDD-091).
  * `BridgeEvent::PttCapability` mirrors the event across the
    FFI boundary. flutter_rust_bridge codegen regenerated.
  * Flutter `_AudioControls` renders a capability badge above the
    PTT button: a globe icon for Global levels, a focus-frame
    icon for `L0Focused`, plus a Tooltip exposing the bound input
    class. New ARB key `pttCapabilityBadge(level, backend)` in
    `app_en.arb` and `app_zh.arb`.

Per-platform global PTT backends (`WindowsRawInputBackend`,
`MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) and the
`MissedKeyUpWatchdog` task land in a separate follow-up commit;
this milestone ships only PTT-L0 universally so the application's
runtime capability reporting is honest from day one.

Tests
-----

* `chanora_audio` rises from 1 to 4 unit tests covering
  `PttCapabilityLevel::as_str`, `is_global`, and the
  `PttBackendDescriptor::focused()` shape contract.
* `chanora_diagnostics` rises from 9 to 11 unit tests covering
  the new `PttBanCheckVisitor` over every banned field name and
  the `PTT_BANNED_FIELDS` stability assertion.
* Workspace total: 53 unit + integration tests, all green with
  `CHANORA_DISABLE_KEYRING=1` (was 49 at v1.0.0-rc.2).
* `flutter analyze`: clean.
* `cargo deny check`: advisories ok, bans ok, licenses ok,
  sources ok.
* `cargo about generate`: zero warnings (license inventory
  regenerated).
* `tools/dump_flutter_licenses.sh`: 94 packages, zero without
  LICENSE.
* Linux x86_64 release bundle builds clean.

No Android live verification in this commit per the user's note
that the test device was removed. Android arm64-v8a continues to
build via the same `cargo ndk` path; runtime reporting on Android
is `L0Focused` for the foreseeable future.
This commit is contained in:
EdisonJwa
2026-05-15 15:02:03 +08:00
parent 02ffadfa52
commit 7b21916049
16 changed files with 642 additions and 31 deletions
+7
View File
@@ -32,6 +32,13 @@
"startAudioAction": "Start audio",
"pttHoldToTalk": "Hold to talk",
"pttTransmitting": "Transmitting…",
"pttCapabilityBadge": "PTT: {level} ({backend})",
"@pttCapabilityBadge": {
"placeholders": {
"level": { "type": "String" },
"backend": { "type": "String" }
}
},
"inputMuteAction": "Mute mic",
"inputUnmuteAction": "Unmute mic",
"outputMuteAction": "Mute speaker",
+1
View File
@@ -28,6 +28,7 @@
"startAudioAction": "启动语音",
"pttHoldToTalk": "按住说话",
"pttTransmitting": "正在发送…",
"pttCapabilityBadge": "对讲能力:{level}{backend}",
"inputMuteAction": "静音麦克风",
"inputUnmuteAction": "取消麦克风静音",
"outputMuteAction": "静音扬声器",
@@ -241,6 +241,12 @@ abstract class AppL10n {
/// **'Transmitting…'**
String get pttTransmitting;
/// No description provided for @pttCapabilityBadge.
///
/// In en, this message translates to:
/// **'PTT: {level} ({backend})'**
String pttCapabilityBadge(String level, String backend);
/// No description provided for @inputMuteAction.
///
/// In en, this message translates to:
@@ -87,6 +87,11 @@ class AppL10nEn extends AppL10n {
@override
String get pttTransmitting => 'Transmitting…';
@override
String pttCapabilityBadge(String level, String backend) {
return 'PTT: $level ($backend)';
}
@override
String get inputMuteAction => 'Mute mic';
@@ -85,6 +85,11 @@ class AppL10nZh extends AppL10n {
@override
String get pttTransmitting => '正在发送…';
@override
String pttCapabilityBadge(String level, String backend) {
return '对讲能力:$level$backend';
}
@override
String get inputMuteAction => '静音麦克风';
+62
View File
@@ -107,6 +107,14 @@ class _BetaHomeState extends State<_BetaHome> {
bool _outputMuted = false;
double _outputGain = 1.0;
// Desktop PTT capability badge state (gen2 v0.9.3 / SDD-091).
// Populated by `BridgeEvent.PttCapability`. Defaults match the
// universal `FocusedPttBackend::focused()` descriptor so the UI
// shows an honest baseline even before the first event arrives.
String _pttLevel = 'L0Focused';
String _pttBackendId = 'focused';
String _pttBoundInputClass = 'keyboard';
List<rust.BridgeBookmark> _bookmarks = const [];
@override
@@ -160,6 +168,16 @@ class _BetaHomeState extends State<_BetaHome> {
setState(() => _audioStarted = false);
case rust.BridgeEvent_SnapshotChanged():
unawaited(_onRefresh());
case rust.BridgeEvent_PttCapability(
:final level,
:final backendId,
:final boundInputClass,
):
setState(() {
_pttLevel = level;
_pttBackendId = backendId;
_pttBoundInputClass = boundInputClass;
});
}
}
@@ -644,6 +662,9 @@ class _BetaHomeState extends State<_BetaHome> {
inputMuted: _inputMuted,
outputMuted: _outputMuted,
outputGain: _outputGain,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
onPttDown: () => _setPtt(true),
onPttUp: () => _setPtt(false),
onToggleInputMute: _toggleInputMute,
@@ -798,6 +819,9 @@ class _AudioControls extends StatefulWidget {
required this.inputMuted,
required this.outputMuted,
required this.outputGain,
required this.pttLevel,
required this.pttBackendId,
required this.pttBoundInputClass,
required this.onPttDown,
required this.onPttUp,
required this.onToggleInputMute,
@@ -809,6 +833,9 @@ class _AudioControls extends StatefulWidget {
final bool inputMuted;
final bool outputMuted;
final double outputGain;
final String pttLevel;
final String pttBackendId;
final String pttBoundInputClass;
final VoidCallback onPttDown;
final VoidCallback onPttUp;
final VoidCallback onToggleInputMute;
@@ -835,9 +862,44 @@ 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,
),
),
),
],
),
),
),
Listener(
onPointerDown: (_) {
setState(() => _pressed = true);
@@ -323,6 +323,25 @@ sealed class BridgeEvent with _$BridgeEvent {
/// Latest client count.
required int clients,
}) = BridgeEvent_SnapshotChanged;
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3,
/// DEC-023..028). The fields carry only privacy-safe values per
/// DEC-027: the capability level, a stable backend identifier,
/// and the bound input class. No key codes, scan codes, or
/// virtual-key values cross this boundary.
const factory BridgeEvent.pttCapability({
/// Stable capability identifier (`"L0Focused"`,
/// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`,
/// `"L3GlobalWithMouseButtons"`, or `"L4DeviceAware"`).
required String level,
/// Stable backend identifier (e.g. `"focused"`).
required String backendId,
/// Coarse bound input class (e.g. `"keyboard"`,
/// `"mouse-side-button"`); empty when no binding is active.
required String boundInputClass,
}) = BridgeEvent_PttCapability;
}
/// Coarse OS-reported network state. Mirrors
@@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent {
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,required TResult orElse(),}){
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,required TResult orElse(),}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -65,7 +65,8 @@ return reconnecting(_that);case BridgeEvent_Disconnected() when disconnected !=
return disconnected(_that);case BridgeEvent_AudioStarted() when audioStarted != null:
return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != null:
return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChanged != null:
return snapshotChanged(_that);case _:
return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapability != null:
return pttCapability(_that);case _:
return orElse();
}
@@ -83,7 +84,7 @@ return snapshotChanged(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,}){
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected():
@@ -93,7 +94,8 @@ return reconnecting(_that);case BridgeEvent_Disconnected():
return disconnected(_that);case BridgeEvent_AudioStarted():
return audioStarted(_that);case BridgeEvent_AudioStopped():
return audioStopped(_that);case BridgeEvent_SnapshotChanged():
return snapshotChanged(_that);}
return snapshotChanged(_that);case BridgeEvent_PttCapability():
return pttCapability(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
@@ -107,7 +109,7 @@ return snapshotChanged(_that);}
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,}){
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -117,7 +119,8 @@ return reconnecting(_that);case BridgeEvent_Disconnected() when disconnected !=
return disconnected(_that);case BridgeEvent_AudioStarted() when audioStarted != null:
return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != null:
return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChanged != null:
return snapshotChanged(_that);case _:
return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapability != null:
return pttCapability(_that);case _:
return null;
}
@@ -134,7 +137,7 @@ return snapshotChanged(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -143,7 +146,8 @@ return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected
return disconnected(_that.reason);case BridgeEvent_AudioStarted() when audioStarted != null:
return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null:
return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged != null:
return snapshotChanged(_that.channels,_that.clients);case _:
return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability() when pttCapability != null:
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case _:
return orElse();
}
@@ -161,7 +165,7 @@ return snapshotChanged(_that.channels,_that.clients);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected():
return connected(_that.serverName);case BridgeEvent_Lost():
@@ -170,7 +174,8 @@ return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected
return disconnected(_that.reason);case BridgeEvent_AudioStarted():
return audioStarted();case BridgeEvent_AudioStopped():
return audioStopped();case BridgeEvent_SnapshotChanged():
return snapshotChanged(_that.channels,_that.clients);}
return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability():
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -184,7 +189,7 @@ return snapshotChanged(_that.channels,_that.clients);}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -193,7 +198,8 @@ return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected
return disconnected(_that.reason);case BridgeEvent_AudioStarted() when audioStarted != null:
return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null:
return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged != null:
return snapshotChanged(_that.channels,_that.clients);case _:
return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability() when pttCapability != null:
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case _:
return null;
}
@@ -604,6 +610,82 @@ as int,
}
}
/// @nodoc
class BridgeEvent_PttCapability extends BridgeEvent {
const BridgeEvent_PttCapability({required this.level, required this.backendId, required this.boundInputClass}): super._();
/// Stable capability identifier (`"L0Focused"`,
/// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`,
/// `"L3GlobalWithMouseButtons"`, or `"L4DeviceAware"`).
final String level;
/// Stable backend identifier (e.g. `"focused"`).
final String backendId;
/// Coarse bound input class (e.g. `"keyboard"`,
/// `"mouse-side-button"`); empty when no binding is active.
final String boundInputClass;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeEvent_PttCapabilityCopyWith<BridgeEvent_PttCapability> get copyWith => _$BridgeEvent_PttCapabilityCopyWithImpl<BridgeEvent_PttCapability>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_PttCapability&&(identical(other.level, level) || other.level == level)&&(identical(other.backendId, backendId) || other.backendId == backendId)&&(identical(other.boundInputClass, boundInputClass) || other.boundInputClass == boundInputClass));
}
@override
int get hashCode => Object.hash(runtimeType,level,backendId,boundInputClass);
@override
String toString() {
return 'BridgeEvent.pttCapability(level: $level, backendId: $backendId, boundInputClass: $boundInputClass)';
}
}
/// @nodoc
abstract mixin class $BridgeEvent_PttCapabilityCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
factory $BridgeEvent_PttCapabilityCopyWith(BridgeEvent_PttCapability value, $Res Function(BridgeEvent_PttCapability) _then) = _$BridgeEvent_PttCapabilityCopyWithImpl;
@useResult
$Res call({
String level, String backendId, String boundInputClass
});
}
/// @nodoc
class _$BridgeEvent_PttCapabilityCopyWithImpl<$Res>
implements $BridgeEvent_PttCapabilityCopyWith<$Res> {
_$BridgeEvent_PttCapabilityCopyWithImpl(this._self, this._then);
final BridgeEvent_PttCapability _self;
final $Res Function(BridgeEvent_PttCapability) _then;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? level = null,Object? backendId = null,Object? boundInputClass = null,}) {
return _then(BridgeEvent_PttCapability(
level: null == level ? _self.level : level // ignore: cast_nullable_to_non_nullable
as String,backendId: null == backendId ? _self.backendId : backendId // ignore: cast_nullable_to_non_nullable
as String,boundInputClass: null == boundInputClass ? _self.boundInputClass : boundInputClass // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on
@@ -830,6 +830,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
channels: dco_decode_u_32(raw[1]),
clients: dco_decode_u_32(raw[2]),
);
case 7:
return BridgeEvent_PttCapability(
level: dco_decode_String(raw[1]),
backendId: dco_decode_String(raw[2]),
boundInputClass: dco_decode_String(raw[3]),
);
default:
throw Exception("unreachable");
}
@@ -1074,6 +1080,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
channels: var_channels,
clients: var_clients,
);
case 7:
var var_level = sse_decode_String(deserializer);
var var_backendId = sse_decode_String(deserializer);
var var_boundInputClass = sse_decode_String(deserializer);
return BridgeEvent_PttCapability(
level: var_level,
backendId: var_backendId,
boundInputClass: var_boundInputClass,
);
default:
throw UnimplementedError('');
}
@@ -1340,6 +1355,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_i_32(6, serializer);
sse_encode_u_32(channels, serializer);
sse_encode_u_32(clients, serializer);
case BridgeEvent_PttCapability(
level: final level,
backendId: final backendId,
boundInputClass: final boundInputClass,
):
sse_encode_i_32(7, serializer);
sse_encode_String(level, serializer);
sse_encode_String(backendId, serializer);
sse_encode_String(boundInputClass, serializer);
}
}
+30 -1
View File
@@ -45,7 +45,9 @@ use tokio::sync::{broadcast, oneshot, watch, Mutex};
use tokio::task::JoinHandle;
use tracing::{info, warn};
pub use chanora_audio::{AudioEngine, AudioEngineConfig};
pub use chanora_audio::{
AudioEngine, AudioEngineConfig, PttBackendDescriptor, PttCapabilityLevel,
};
pub use chanora_diagnostics::{
DiagnosticExport, InMemoryLogSink, KnownSecretRegistry, RedactingLogLayer, Redactor,
};
@@ -133,6 +135,21 @@ pub enum SessionEvent {
/// Number of clients in the latest probe snapshot.
clients: u32,
},
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
/// DEC-023..028). Published when the audio engine starts or
/// when the active backend transitions (for example macOS
/// permission state change). Carries only the privacy-safe
/// descriptor — capability level, backend identifier, bound
/// input class — per SRS-202 / DEC-027.
PttCapability {
/// Stable level name from `PttCapabilityLevel::as_str()`.
level: String,
/// Stable backend identifier (e.g. `"focused"`).
backend_id: String,
/// Coarse bound input class (e.g. `"keyboard"`); empty when
/// no binding is active.
bound_input_class: String,
},
}
/// Coarse OS-reported network state. Populated by the Flutter side
@@ -437,6 +454,18 @@ 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). The current
// milestone ships only the universal Focused backend
// (PTT-L0); per-platform global backends arrive in a
// follow-up code commit. The descriptor is privacy-safe
// by construction (DEC-027).
let desc = PttBackendDescriptor::focused();
let _ = self.events_tx.send(SessionEvent::PttCapability {
level: desc.level.as_str().to_string(),
backend_id: desc.backend_id.to_string(),
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
});
Ok(())
}
+49 -18
View File
@@ -77,7 +77,13 @@ impl Default for AudioEngineConfig {
/// Running audio engine. Drop = stop.
pub struct AudioEngine {
ptt: Arc<AtomicBool>,
/// `transmit_active` is the authoritative gate for outbound
/// voice — the Opus encoder feed consults this flag once per
/// 20 ms frame. PTT subsystems (focused widget, future
/// Windows / macOS / Linux global backends) drive this flag
/// through [`Self::set_transmit_active`]; nothing else is
/// permitted to flip it (SAD-075 / SDD-089).
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
frames_received: Arc<AtomicU32>,
/// Master output gain as f32 bits in an AtomicU32. Default 1.0.
@@ -181,7 +187,7 @@ impl AudioEngine {
}
}
let ptt = Arc::new(AtomicBool::new(cfg.ptt_initial));
let transmit_active = Arc::new(AtomicBool::new(cfg.ptt_initial));
let frames_sent = Arc::new(AtomicU32::new(0));
let frames_received = Arc::new(AtomicU32::new(0));
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
@@ -196,7 +202,7 @@ impl AudioEngine {
let capture_result = try_open_capture(
&in_dev,
voice_out_tx,
ptt.clone(),
transmit_active.clone(),
frames_sent.clone(),
cfg.mic_gain,
);
@@ -293,7 +299,7 @@ impl AudioEngine {
});
Ok(Self {
ptt,
transmit_active,
frames_sent,
frames_received,
output_gain,
@@ -316,19 +322,41 @@ impl AudioEngine {
info!(target: "chanora_audio", "audio engine stopped");
}
/// Set the push-to-talk active state. When false, captured audio
/// is discarded before encoding. No-op if capture is inactive.
pub fn set_ptt(&self, active: bool) {
self.ptt.store(active, Ordering::Relaxed);
/// Set the **transmission gate** (SRS-201). When true the
/// encoder feed is allowed to emit Opus frames; when false the
/// captured audio is discarded before encoding. This is the
/// only writer permitted on `transmit_active` (SAD-075 /
/// SDD-089). Push-to-Talk subsystems — focused PTT today,
/// per-platform global backends in a follow-up — call this
/// method exclusively. No-op when capture is inactive.
pub fn set_transmit_active(&self, active: bool) {
self.transmit_active.store(active, Ordering::Relaxed);
}
/// Current PTT state.
/// Current transmit gate state.
pub fn transmit_active(&self) -> bool {
self.transmit_active.load(Ordering::Relaxed)
}
/// 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
/// migration (SRS-201 splits the conceptual `ptt` flag into
/// `transmit_active` / `capture_active`).
#[doc(hidden)]
pub fn set_ptt(&self, active: bool) {
self.set_transmit_active(active);
}
/// Legacy alias for [`Self::transmit_active`].
#[doc(hidden)]
pub fn ptt(&self) -> bool {
self.ptt.load(Ordering::Relaxed)
self.transmit_active()
}
/// True if the capture stream opened. When false, the engine
/// runs in playback-only mode and PTT is a no-op.
/// runs in playback-only mode and the transmit gate is a
/// no-op (no frames will ever be encoded).
pub fn capture_active(&self) -> bool {
self.capture_active
}
@@ -380,7 +408,7 @@ impl Drop for AudioEngine {
fn try_open_capture(
in_dev: &cpal::Device,
voice_out_tx: mpsc::Sender<OutPacket>,
ptt: Arc<AtomicBool>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
) -> Result<cpal::Stream, AudioError> {
@@ -405,7 +433,7 @@ fn try_open_capture(
in_channels,
mic_gain,
voice_out_tx,
ptt,
transmit_active,
frames_sent,
)));
@@ -433,7 +461,9 @@ struct CaptureState {
resample_pos: f64,
opus_out: [u8; MAX_OPUS_FRAME],
voice_out_tx: mpsc::Sender<OutPacket>,
ptt: Arc<AtomicBool>,
/// The PTT transmission gate. Read once per outbound frame; the
/// CaptureState never mutates this flag.
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
}
@@ -444,7 +474,7 @@ impl CaptureState {
in_channels: usize,
mic_gain: f32,
voice_out_tx: mpsc::Sender<OutPacket>,
ptt: Arc<AtomicBool>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
) -> Self {
Self {
@@ -456,15 +486,16 @@ impl CaptureState {
resample_pos: 0.0,
opus_out: [0u8; MAX_OPUS_FRAME],
voice_out_tx,
ptt,
transmit_active,
frames_sent,
}
}
/// Consume an arbitrary-rate, multichannel cpal buffer; produce
/// 48 kHz mono frames; encode and send on PTT.
/// 48 kHz mono frames; encode and send when `transmit_active`
/// is true (PTT engaged).
fn ingest<T: ToF32 + Copy>(&mut self, buf: &[T]) {
if !self.ptt.load(Ordering::Relaxed) {
if !self.transmit_active.load(Ordering::Relaxed) {
// Drain accumulator while muted so we don't pop on PTT release.
self.pcm_accum.clear();
return;
+2
View File
@@ -29,8 +29,10 @@
#![warn(missing_docs)]
mod engine;
pub mod ptt;
pub use engine::{AudioEngine, AudioEngineConfig};
pub use ptt::{PttCapabilityLevel, PttBackendDescriptor};
use thiserror::Error;
+161
View File
@@ -0,0 +1,161 @@
//! Desktop Push-to-Talk capability model (SRS-195 / SRS-196 / SAD-071 /
//! SDD-081 / SDD-082).
//!
//! This module defines the typed `PttCapabilityLevel` enum and a
//! lightweight `PttBackendDescriptor` value the audio engine
//! publishes to upstream consumers so the UI can render the live
//! capability badge (SDD-091) and the release verification record
//! can carry per-platform evidence (SysDes-148).
//!
//! Concrete platform backends (`WindowsRawInputBackend`,
//! `MacOSEventTapBackend`, `LinuxGnomeWaylandBackend`) are deferred
//! to a follow-up code milestone; this commit lands the trait shape
//! and the universal `FocusedPttBackend` constant value so the
//! current Flutter-side hold-to-talk widget reports its capability
//! honestly through the bridge.
use core::fmt;
/// Detected runtime capability of the active desktop PTT backend.
///
/// The reported value shall match runtime behaviour — a backend
/// that *could* deliver `L2GlobalHoldToTalk` but lacks the
/// user-granted permission or the required compositor support
/// reports `L0Focused` (SRS-196 / SysRS-298).
///
/// `L4DeviceAware` is reserved per the gen2 v0.9.3 baseline
/// (SDD-082) and is **not** produced by any MVP implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PttCapabilityLevel {
/// Focused PTT. Press / release works only while the
/// application window has input focus. Mandatory baseline on
/// every desktop platform per SysRS-296.
L0Focused,
/// Global shortcut activation. The OS recognises a global
/// accelerator and notifies the application, but hold-to-talk
/// semantics may be approximated rather than guaranteed.
L1GlobalShortcut,
/// Global hold-to-talk. Press and release events are delivered
/// while the application is not focused.
L2GlobalHoldToTalk,
/// Global hold-to-talk plus mouse side buttons (typically
/// Mouse4 / Mouse5, sometimes labelled "back" / "forward").
L3GlobalWithMouseButtons,
/// Device-aware PTT. Reserved; no MVP implementation produces
/// this value (SDD-082).
L4DeviceAware,
}
impl PttCapabilityLevel {
/// Short identifier used by the diagnostics sanitizer and by
/// the release verification record. Stable across releases —
/// release notes and platform-test traces compare against these
/// strings.
pub fn as_str(self) -> &'static str {
match self {
Self::L0Focused => "L0Focused",
Self::L1GlobalShortcut => "L1GlobalShortcut",
Self::L2GlobalHoldToTalk => "L2GlobalHoldToTalk",
Self::L3GlobalWithMouseButtons => "L3GlobalWithMouseButtons",
Self::L4DeviceAware => "L4DeviceAware",
}
}
/// True when the level represents a global (non-focused)
/// behaviour. UI consumers use this to gate the "global PTT
/// available" affordance.
pub fn is_global(self) -> bool {
matches!(
self,
Self::L1GlobalShortcut
| Self::L2GlobalHoldToTalk
| Self::L3GlobalWithMouseButtons
| Self::L4DeviceAware
)
}
}
impl fmt::Display for PttCapabilityLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Diagnostics-safe descriptor of the active PTT backend. Carries
/// only the fields the privacy policy permits in the user-initiated
/// diagnostic export (SRS-202 / DEC-027): the detected capability
/// level, a fixed `backend_id` string per implementation, and an
/// optional bound-input class (`"keyboard"`, `"mouse-side-button"`,
/// …). The raw key code, scan code, virtual-key value, or keysym
/// of any user binding is **never** part of this structure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PttBackendDescriptor {
/// Detected runtime capability.
pub level: PttCapabilityLevel,
/// Stable identifier per implementation. Examples:
/// `"focused"`, `"raw-input"`, `"low-level-hook"`,
/// `"event-tap"`, `"gnome-wayland-portal"`.
pub backend_id: &'static str,
/// Coarse description of the bound input. `None` when no
/// binding is active. The value is a stable category string,
/// never a key code.
pub bound_input_class: Option<&'static str>,
}
impl PttBackendDescriptor {
/// The universal Focused-PTT fallback (SDD-087). Every desktop
/// platform reports this value until a platform-specific
/// global backend lands in a follow-up code milestone.
pub const fn focused() -> Self {
Self {
level: PttCapabilityLevel::L0Focused,
backend_id: "focused",
bound_input_class: Some("keyboard"),
}
}
}
impl Default for PttBackendDescriptor {
fn default() -> Self {
Self::focused()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_as_str_is_stable() {
assert_eq!(PttCapabilityLevel::L0Focused.as_str(), "L0Focused");
assert_eq!(
PttCapabilityLevel::L2GlobalHoldToTalk.as_str(),
"L2GlobalHoldToTalk"
);
assert_eq!(
PttCapabilityLevel::L3GlobalWithMouseButtons.as_str(),
"L3GlobalWithMouseButtons"
);
}
#[test]
fn level_is_global_classification() {
assert!(!PttCapabilityLevel::L0Focused.is_global());
assert!(PttCapabilityLevel::L1GlobalShortcut.is_global());
assert!(PttCapabilityLevel::L2GlobalHoldToTalk.is_global());
assert!(PttCapabilityLevel::L3GlobalWithMouseButtons.is_global());
assert!(PttCapabilityLevel::L4DeviceAware.is_global());
}
#[test]
fn focused_descriptor_carries_only_safe_fields() {
let d = PttBackendDescriptor::focused();
assert_eq!(d.level, PttCapabilityLevel::L0Focused);
assert_eq!(d.backend_id, "focused");
assert_eq!(d.bound_input_class, Some("keyboard"));
// Compile-time check: the struct shape itself excludes
// anything that could carry a key code (DEC-027).
let _: &str = d.backend_id;
let _: Option<&str> = d.bound_input_class;
}
}
+25
View File
@@ -510,6 +510,22 @@ pub enum BridgeEvent {
/// Latest client count.
clients: u32,
},
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3,
/// DEC-023..028). The fields carry only privacy-safe values per
/// DEC-027: the capability level, a stable backend identifier,
/// and the bound input class. No key codes, scan codes, or
/// virtual-key values cross this boundary.
PttCapability {
/// Stable capability identifier (`"L0Focused"`,
/// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`,
/// `"L3GlobalWithMouseButtons"`, or `"L4DeviceAware"`).
level: String,
/// Stable backend identifier (e.g. `"focused"`).
backend_id: String,
/// Coarse bound input class (e.g. `"keyboard"`,
/// `"mouse-side-button"`); empty when no binding is active.
bound_input_class: String,
},
}
impl From<chanora_core::SessionEvent> for BridgeEvent {
@@ -534,6 +550,15 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
chanora_core::SessionEvent::SnapshotChanged { channels, clients } => {
BridgeEvent::SnapshotChanged { channels, clients }
}
chanora_core::SessionEvent::PttCapability {
level,
backend_id,
bound_input_class,
} => BridgeEvent::PttCapability {
level,
backend_id,
bound_input_class,
},
}
}
}
@@ -929,6 +929,16 @@ impl SseDecode for crate::api::BridgeEvent {
clients: var_clients,
};
}
7 => {
let mut var_level = <String>::sse_decode(deserializer);
let mut var_backendId = <String>::sse_decode(deserializer);
let mut var_boundInputClass = <String>::sse_decode(deserializer);
return crate::api::BridgeEvent::PttCapability {
level: var_level,
backend_id: var_backendId,
bound_input_class: var_boundInputClass,
};
}
_ => {
unimplemented!("");
}
@@ -1247,6 +1257,17 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
clients.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::PttCapability {
level,
backend_id,
bound_input_class,
} => [
7.into_dart(),
level.into_into_dart().into_dart(),
backend_id.into_into_dart().into_dart(),
bound_input_class.into_into_dart().into_dart(),
]
.into_dart(),
_ => {
unimplemented!("");
}
@@ -1440,6 +1461,16 @@ impl SseEncode for crate::api::BridgeEvent {
<u32>::sse_encode(channels, serializer);
<u32>::sse_encode(clients, serializer);
}
crate::api::BridgeEvent::PttCapability {
level,
backend_id,
bound_input_class,
} => {
<i32>::sse_encode(7, serializer);
<String>::sse_encode(level, serializer);
<String>::sse_encode(backend_id, serializer);
<String>::sse_encode(bound_input_class, serializer);
}
_ => {
unimplemented!("");
}
+121
View File
@@ -384,8 +384,35 @@ impl InMemoryLogSink {
}
}
/// Field names that name a raw key value or key-press timing
/// sequence. Records carrying any of these names are dropped before
/// they reach the log sink (REDACT-PTT-001..006 in
/// `docs/security/diagnostic-redaction-audit-report.md`; SDD-090).
///
/// We compare by exact field name rather than a content scan —
/// partial-redaction false negatives are riskier than a missing
/// log line, and the audio-engine and PTT-backend code paths emit
/// records with stable field names that we control.
const PTT_BANNED_FIELDS: &[&str] = &[
"key_code",
"scan_code",
"virtual_key",
"vk",
"keysym",
"keysym_string",
"key_sequence",
"key_press_history",
"key_timing",
];
/// 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.
#[derive(Debug, Clone)]
pub struct RedactingLogLayer {
sink: InMemoryLogSink,
@@ -403,6 +430,17 @@ 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.
let mut ban_check = PttBanCheckVisitor::default();
event.record(&mut ban_check);
if ban_check.banned {
return;
}
let mut visitor = FormatVisitor::default();
event.record(&mut visitor);
let line = format!(
@@ -418,6 +456,40 @@ where
fn on_new_span(&self, _: &Attributes<'_>, _: &Id, _: Context<'_, S>) {}
}
/// Lightweight `tracing::field::Visit` implementation that only
/// notes whether any visited field name matches the PTT banned
/// list. Allocation-free.
#[derive(Default)]
struct PttBanCheckVisitor {
banned: bool,
}
impl PttBanCheckVisitor {
fn check(&mut self, name: &str) {
if !self.banned && PTT_BANNED_FIELDS.iter().any(|b| *b == name) {
self.banned = true;
}
}
}
impl Visit for PttBanCheckVisitor {
fn record_debug(&mut self, field: &Field, _value: &dyn std::fmt::Debug) {
self.check(field.name());
}
fn record_str(&mut self, field: &Field, _value: &str) {
self.check(field.name());
}
fn record_i64(&mut self, field: &Field, _value: i64) {
self.check(field.name());
}
fn record_u64(&mut self, field: &Field, _value: u64) {
self.check(field.name());
}
fn record_bool(&mut self, field: &Field, _value: bool) {
self.check(field.name());
}
}
#[derive(Default)]
struct FormatVisitor {
message: String,
@@ -577,4 +649,53 @@ mod tests {
assert!(!txt.contains("10.0.0.1"));
assert!(txt.contains("[ip]") || txt.contains(REDACTION_MARKER));
}
#[test]
fn ptt_ban_check_visitor_flags_banned_fields() {
// Direct test of the visitor (we don't spin up a full
// tracing subscriber for this).
let mut v = PttBanCheckVisitor::default();
v.check("backend_id"); // safe
assert!(!v.banned);
v.check("key_code"); // banned
assert!(v.banned);
let mut v2 = PttBanCheckVisitor::default();
for name in [
"scan_code",
"virtual_key",
"vk",
"keysym",
"keysym_string",
"key_sequence",
"key_press_history",
"key_timing",
] {
v2 = PttBanCheckVisitor::default();
v2.check(name);
assert!(v2.banned, "expected {name} to be banned");
}
// Allowed PTT fields stay safe.
let mut v3 = PttBanCheckVisitor::default();
for name in ["capability_level", "backend_id", "bound_input_class"] {
v3.check(name);
}
assert!(!v3.banned);
}
#[test]
fn ptt_banned_list_is_non_empty_and_stable() {
// Lightweight regression catch: the audit document
// REDACT-PTT-001..006 enumerates these exact names.
assert!(PTT_BANNED_FIELDS.contains(&"key_code"));
assert!(PTT_BANNED_FIELDS.contains(&"scan_code"));
assert!(PTT_BANNED_FIELDS.contains(&"virtual_key"));
assert!(PTT_BANNED_FIELDS.contains(&"keysym"));
assert!(PTT_BANNED_FIELDS.contains(&"key_sequence"));
// No accidental additions of safe field names.
for safe in ["capability_level", "backend_id", "bound_input_class"] {
assert!(!PTT_BANNED_FIELDS.contains(&safe));
}
}
}