diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 80747d3..4d4b2bb 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -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", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index b50af3c..ceba9d1 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -28,6 +28,7 @@ "startAudioAction": "启动语音", "pttHoldToTalk": "按住说话", "pttTransmitting": "正在发送…", + "pttCapabilityBadge": "对讲能力:{level}({backend})", "inputMuteAction": "静音麦克风", "inputUnmuteAction": "取消麦克风静音", "outputMuteAction": "静音扬声器", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 3bc0be1..ff76e4e 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -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: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 0c17a20..3f84b86 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -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'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 0f92e87..e103e5b 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -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 => '静音麦克风'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 8c2d38c..f86321a 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -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 _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); diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 9b21dd2..766d683 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -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 diff --git a/apps/chanora_flutter/lib/src/rust/api.freezed.dart b/apps/chanora_flutter/lib/src/rust/api.freezed.dart index 039a10e..43ba102 100644 --- a/apps/chanora_flutter/lib/src/rust/api.freezed.dart +++ b/apps/chanora_flutter/lib/src/rust/api.freezed.dart @@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent { /// } /// ``` -@optionalTypeArgs TResult maybeMap({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 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({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({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? 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? 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 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 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({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({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? 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? 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 get copyWith => _$BridgeEvent_PttCapabilityCopyWithImpl(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 diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index d7d4b68..5587928 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -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); } } diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 2407fd4..c4e58c5 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -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(()) } diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 89e258d..b3c8a3a 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -77,7 +77,13 @@ impl Default for AudioEngineConfig { /// Running audio engine. Drop = stop. pub struct AudioEngine { - ptt: Arc, + /// `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, frames_sent: Arc, frames_received: Arc, /// 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, - ptt: Arc, + transmit_active: Arc, frames_sent: Arc, mic_gain: f32, ) -> Result { @@ -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, - ptt: Arc, + /// The PTT transmission gate. Read once per outbound frame; the + /// CaptureState never mutates this flag. + transmit_active: Arc, frames_sent: Arc, } @@ -444,7 +474,7 @@ impl CaptureState { in_channels: usize, mic_gain: f32, voice_out_tx: mpsc::Sender, - ptt: Arc, + transmit_active: Arc, frames_sent: Arc, ) -> 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(&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; diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index 395d41d..2b60bea 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -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; diff --git a/crates/chanora_audio/src/ptt.rs b/crates/chanora_audio/src/ptt.rs new file mode 100644 index 0000000..8888b07 --- /dev/null +++ b/crates/chanora_audio/src/ptt.rs @@ -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; + } +} diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 2456ba7..17a2ba6 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -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 for BridgeEvent { @@ -534,6 +550,15 @@ impl From 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, + }, } } } diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 32a9db6..38d5773 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -929,6 +929,16 @@ impl SseDecode for crate::api::BridgeEvent { clients: var_clients, }; } + 7 => { + let mut var_level = ::sse_decode(deserializer); + let mut var_backendId = ::sse_decode(deserializer); + let mut var_boundInputClass = ::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 { ::sse_encode(channels, serializer); ::sse_encode(clients, serializer); } + crate::api::BridgeEvent::PttCapability { + level, + backend_id, + bound_input_class, + } => { + ::sse_encode(7, serializer); + ::sse_encode(level, serializer); + ::sse_encode(backend_id, serializer); + ::sse_encode(bound_input_class, serializer); + } _ => { unimplemented!(""); } diff --git a/crates/chanora_diagnostics/src/lib.rs b/crates/chanora_diagnostics/src/lib.rs index d11d833..899efb6 100644 --- a/crates/chanora_diagnostics/src/lib.rs +++ b/crates/chanora_diagnostics/src/lib.rs @@ -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)); + } + } }