From 8cd919cffca6ec0f442a11a7e1cb17d7e1b0dbcf Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Fri, 15 May 2026 23:45:20 +0800 Subject: [PATCH] fix(audio,storage,ui): allow PTT binding before audio is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save-binding before joining a voice channel used to return BridgeError.invalidCommand(audio not started) because the PttController only exists after start_audio runs and set_ptt_binding required a live controller. Users naturally want to bind their PTT key once on first launch, not every time they join a channel — fix: * chanora_storage::IdentityFileStore::set_ptt_binding / get_ptt_binding persist the privacy-safe binding triple (input_class, platform_key, key_label) into audio_meta.json next to transmit_mode and release_tail_ms. * ChanoraSession holds pending_binding: Arc>>. set_ptt_binding now (1) persists to storage best-effort, (2) stashes into pending_binding, (3) forwards live to the controller only if one exists. No more AudioNotStarted. * init_storage loads the persisted binding into pending_binding so it survives app restarts. * start_audio applies pending_binding immediately after constructing the PttController so the first key-press after join already works. * supervisor_loop carries pending_binding and re-applies it after any reconnect-driven audio engine restart, so reconnects don't silently drop the hotkey. * New bridge call get_ptt_binding() -> (input_class, key_label) plus a matching Flutter _hydratePttBinding() in initState lets the Voice Bar show the user's saved hotkey label on launch (e.g. 'PTT: Space') before any voice channel is joined. cargo test --workspace --lib: 72 passed / 0 failed / 1 ignored. flutter analyze: clean (6 pre-existing Radio.groupValue infos). FRB bindings regenerated. --- apps/chanora_flutter/lib/main.dart | 21 +++ apps/chanora_flutter/lib/src/rust/api.dart | 8 ++ .../lib/src/rust/frb_generated.dart | 103 +++++++++++--- .../lib/src/rust/frb_generated.io.dart | 14 ++ .../lib/src/rust/frb_generated.web.dart | 14 ++ core/chanora_core/src/lib.rs | 134 ++++++++++++++++-- crates/chanora_bridge/src/api.rs | 12 ++ crates/chanora_bridge/src/frb_generated.rs | 97 ++++++++++--- crates/chanora_storage/src/lib.rs | 46 ++++++ 9 files changed, 394 insertions(+), 55 deletions(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 1e22dee..4ed1b1b 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -145,6 +145,27 @@ class _BetaHomeState extends State<_BetaHome> { super.initState(); _eventsSub = rust.eventsStream().listen(_onEvent); unawaited(_reloadBookmarks()); + unawaited(_hydratePttBinding()); + } + + /// Hydrate the bound-key display state from the bridge so the + /// Voice Bar shows the user's persisted hotkey label immediately + /// at launch — without having to wait for them to open the + /// binding dialog. The bridge returns empty strings when no + /// binding has ever been saved. + Future _hydratePttBinding() async { + try { + final (inputClass, keyLabel) = await rust.getPttBinding(); + if (!mounted) return; + if (keyLabel.isNotEmpty || inputClass.isNotEmpty) { + setState(() { + _pttBoundKeyLabel = keyLabel; + _pttBoundInputClass = inputClass; + }); + } + } catch (_) { + // No persisted binding or storage not yet wired; not an error. + } } Future _reloadBookmarks() async { diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index f571656..071670b 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -105,6 +105,14 @@ Future setPttBinding({ Future<(String, String, String)> pttDescriptor() => RustLib.instance.api.crateApiPttDescriptor(); +/// Return the persisted PTT binding as a +/// `(input_class, platform_key)` pair so the UI can hydrate its +/// display state at launch (e.g. show "PTT: Space" next to the +/// badge before the user re-opens the binding dialog). Empty +/// strings mean no binding has been persisted yet. +Future<(String, String)> getPttBinding() => + RustLib.instance.api.crateApiGetPttBinding(); + /// Move our own client to `channel_id`. Optional channel password /// for password-protected channels — pass an empty string when not /// required. diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 6867251..14ad68e 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 651188866; + int get rustContentHash => 1306308591; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -99,6 +99,8 @@ abstract class RustLibApi extends BaseApi { String crateApiExportDiagnostics(); + Future<(String, String)> crateApiGetPttBinding(); + Future crateApiGetReleaseTailMs(); Future crateApiGetTransmitMode(); @@ -387,7 +389,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { const TaskConstMeta(debugName: "export_diagnostics", argNames: []); @override - Future crateApiGetReleaseTailMs() { + Future<(String, String)> crateApiGetPttBinding() { return handler.executeNormal( NormalTask( callFfi: (port_) { @@ -399,6 +401,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { port: port_, ); }, + codec: SseCodec( + decodeSuccessData: sse_decode_record_string_string, + decodeErrorData: null, + ), + constMeta: kCrateApiGetPttBindingConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiGetPttBindingConstMeta => + const TaskConstMeta(debugName: "get_ptt_binding", argNames: []); + + @override + Future crateApiGetReleaseTailMs() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 10, + port: port_, + ); + }, codec: SseCodec( decodeSuccessData: sse_decode_u_32, decodeErrorData: null, @@ -422,7 +451,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 10, + funcId: 11, port: port_, ); }, @@ -450,7 +479,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 11, + funcId: 12, port: port_, ); }, @@ -477,7 +506,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 12, + funcId: 13, port: port_, ); }, @@ -504,7 +533,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 13, + funcId: 14, port: port_, ); }, @@ -528,7 +557,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -558,7 +587,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 15, + funcId: 16, port: port_, ); }, @@ -587,7 +616,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 16, + funcId: 17, port: port_, ); }, @@ -615,7 +644,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 17, + funcId: 18, port: port_, ); }, @@ -643,7 +672,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 18, + funcId: 19, port: port_, ); }, @@ -668,7 +697,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_bridge_network_state(state, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -694,7 +723,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 20, + funcId: 21, port: port_, ); }, @@ -722,7 +751,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 21, + funcId: 22, port: port_, ); }, @@ -750,7 +779,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 22, + funcId: 23, port: port_, ); }, @@ -782,7 +811,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 23, + funcId: 24, port: port_, ); }, @@ -812,7 +841,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 24, + funcId: 25, port: port_, ); }, @@ -840,7 +869,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 25, + funcId: 26, port: port_, ); }, @@ -867,7 +896,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 26, + funcId: 27, port: port_, ); }, @@ -895,7 +924,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 27, + funcId: 28, port: port_, ); }, @@ -927,7 +956,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 28, + funcId: 29, port: port_, ); }, @@ -956,7 +985,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 29, + funcId: 30, port: port_, ); }, @@ -1203,6 +1232,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as Uint8List; } + @protected + (String, String) dco_decode_record_string_string(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) { + throw Exception('Expected 2 elements, got ${arr.length}'); + } + return (dco_decode_String(arr[0]), dco_decode_String(arr[1])); + } + @protected (String, String, String) dco_decode_record_string_string_string(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1530,6 +1569,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getUint8List(len_); } + @protected + (String, String) sse_decode_record_string_string( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_field0 = sse_decode_String(deserializer); + var var_field1 = sse_decode_String(deserializer); + return (var_field0, var_field1); + } + @protected (String, String, String) sse_decode_record_string_string_string( SseDeserializer deserializer, @@ -1835,6 +1884,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer.buffer.putUint8List(self); } + @protected + void sse_encode_record_string_string( + (String, String) self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.$1, serializer); + sse_encode_String(self.$2, serializer); + } + @protected void sse_encode_record_string_string_string( (String, String, String) self, diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart index 45c8df0..818ae19 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart @@ -87,6 +87,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + @protected + (String, String) dco_decode_record_string_string(dynamic raw); + @protected (String, String, String) dco_decode_record_string_string_string(dynamic raw); @@ -184,6 +187,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + (String, String) sse_decode_record_string_string( + SseDeserializer deserializer, + ); + @protected (String, String, String) sse_decode_record_string_string_string( SseDeserializer deserializer, @@ -306,6 +314,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_record_string_string( + (String, String) self, + SseSerializer serializer, + ); + @protected void sse_encode_record_string_string_string( (String, String, String) self, diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart index a37c19d..dd8aef8 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart @@ -89,6 +89,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + @protected + (String, String) dco_decode_record_string_string(dynamic raw); + @protected (String, String, String) dco_decode_record_string_string_string(dynamic raw); @@ -186,6 +189,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + (String, String) sse_decode_record_string_string( + SseDeserializer deserializer, + ); + @protected (String, String, String) sse_decode_record_string_string_string( SseDeserializer deserializer, @@ -308,6 +316,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_record_string_string( + (String, String) self, + SseSerializer serializer, + ); + @protected void sse_encode_record_string_string_string( (String, String, String) self, diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 86695c6..c6940a3 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -257,6 +257,14 @@ pub struct ChanoraSession { /// Release-tail timer (SDD-096). Drives the selector's /// `ptt_held` input from PTT key edges. release_tail: Arc, + /// Last PTT binding the user requested via `set_ptt_binding`. + /// Kept here so it survives the gap between user-saving a + /// binding (which may happen before any audio is running) and + /// the audio engine actually coming up. When the engine starts + /// and creates the `PttController`, the controller is seeded + /// from this value. Also persisted to the identity store so + /// the binding survives app restarts. + pending_binding: Arc>>, } impl ChanoraSession { @@ -282,6 +290,7 @@ impl ChanoraSession { bookmark_store: Arc::new(Mutex::new(None)), voice_selector: selector, release_tail, + pending_binding: Arc::new(Mutex::new(None)), } } @@ -322,6 +331,23 @@ impl ChanoraSession { } self.release_tail .set_tail_ms(store.get_release_tail_ms().min(chanora_audio::MAX_TAIL_MS)); + // Restore persisted PTT binding. The controller doesn't + // exist yet (no audio engine running), so we stash the + // binding in `pending_binding`; it gets applied when the + // controller arms inside `start_audio`. + let (input_class_s, platform_key, _key_label) = store.get_ptt_binding(); + if !input_class_s.is_empty() || !platform_key.is_empty() { + let input_class = match input_class_s.as_str() { + "keyboard" => PttInputClass::Keyboard, + "mouse-side-button" => PttInputClass::MouseSideButton, + _ => PttInputClass::None, + }; + let binding = PttBinding { + input_class, + platform_key, + }; + *self.pending_binding.lock().await = Some(binding); + } *self.identity_store.lock().await = Some(store); *self.bookmark_store.lock().await = Some(bookmarks); info!( @@ -454,6 +480,7 @@ impl ChanoraSession { sup_inner.clone(), self.network_tx.subscribe(), self.voice_selector.clone(), + self.pending_binding.clone(), )); let _ = self.events_tx.send(SessionEvent::Connected { @@ -525,6 +552,20 @@ impl ChanoraSession { let controller = ptt::PttController::new(gate); state.ptt_controller = Some(controller.clone()); + // Apply any binding the user saved before audio was running + // (SDD-094 follow-up). Persistence + caching happen in + // `set_ptt_binding`; here we forward the cached value to + // the freshly-armed controller so PTT actually fires. + if let Some(pending) = self.pending_binding.lock().await.clone() { + if let Err(e) = controller.set_binding(pending).await { + warn!( + target: "chanora_core", + error = %e, + "applying pending PTT binding failed; controller stays at default" + ); + } + } + // Record desired state so the supervisor will re-start audio // after a reconnect. { @@ -576,19 +617,48 @@ impl ChanoraSession { /// `PttBackendDescriptor` is broadcast as /// `SessionEvent::PttCapability` so the UI badge updates /// immediately. The audio engine must be running. + /// Update the active PTT binding (gen2 v0.9.3 / DEC-026). + /// + /// This call must succeed even when the audio engine is not + /// running — users may bind a key before they ever join a voice + /// channel. We always persist the binding to the identity store, + /// always store it in `pending_binding`, and only forward it + /// live to the `PttController` when the controller exists. When + /// audio next starts the controller picks up the pending binding + /// (see `start_audio` / `voice_join`). pub async fn set_ptt_binding(&self, binding: PttBinding) -> Result<(), CoreError> { + // 1. Persist to disk (best-effort; missing identity store + // just means storage hasn't been wired yet). + if let Some(store) = self.identity_store.lock().await.as_ref() { + let class_s = match binding.input_class { + PttInputClass::None => "", + PttInputClass::Keyboard => "keyboard", + PttInputClass::MouseSideButton => "mouse-side-button", + }; + if let Err(e) = + store.set_ptt_binding(class_s, &binding.platform_key, &binding.platform_key) + { + warn!( + target: "chanora_core", + error = %e, + "could not persist PTT binding" + ); + } + } + // 2. Stash for future controller arming. + *self.pending_binding.lock().await = Some(binding.clone()); + // 3. Forward live if a controller exists. let guard = self.inner.lock().await; - let state = guard.as_ref().ok_or(CoreError::NotConnected)?; - 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(), - bound_input_class: desc.bound_input_class.unwrap_or("").to_string(), - }); + if let Some(state) = guard.as_ref() { + if let Some(controller) = state.ptt_controller.as_ref() { + 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(), + bound_input_class: desc.bound_input_class.unwrap_or("").to_string(), + }); + } + } Ok(()) } @@ -610,6 +680,31 @@ impl ChanoraSession { ) } + /// Read the persisted PTT binding as `(input_class, key_label)`. + /// Used by the Flutter side at launch so the badge and the + /// mode line can show the user's saved hotkey before any audio + /// engine has spun up. Empty strings indicate no binding has + /// been saved yet. + pub async fn get_ptt_binding(&self) -> (String, String) { + // 1) Prefer the in-memory pending binding (most-recent + // save, possibly not yet flushed to disk on a slow FS). + if let Some(b) = self.pending_binding.lock().await.clone() { + let class_s = match b.input_class { + PttInputClass::None => "", + PttInputClass::Keyboard => "keyboard", + PttInputClass::MouseSideButton => "mouse-side-button", + }; + return (class_s.to_string(), b.platform_key); + } + // 2) Fall back to the persisted file (case: app just + // started, init_storage already ran). + if let Some(store) = self.identity_store.lock().await.as_ref() { + let (class_s, _platform_key, key_label) = store.get_ptt_binding(); + return (class_s, key_label); + } + (String::new(), String::new()) + } + /// Move our own client to `channel_id`. Optional channel /// `password` for password-protected channels (empty string /// counts as no password). @@ -887,6 +982,7 @@ async fn supervisor_loop( sup_inner: Arc>, mut network_rx: watch::Receiver, voice_selector: Arc, + pending_binding: Arc>>, ) { let mut lost_rx = initial_lost_rx; let mut probe = initial_probe; @@ -1178,6 +1274,22 @@ async fn supervisor_loop( // the new engine's gate (SDD-088). let controller = ptt::PttController::new(gate); state.ptt_controller = Some(controller.clone()); + // Re-apply any persisted PTT binding + // so reconnect doesn't silently drop + // the user's hotkey. + if let Some(pending) = + pending_binding.lock().await.clone() + { + if let Err(e) = + controller.set_binding(pending).await + { + warn!( + target: "chanora_core", + error = %e, + "re-applying PTT binding after reconnect failed" + ); + } + } let _ = events_tx .send(SessionEvent::AudioStarted); // Re-publish the post-reconnect diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index c63ccb1..43d2cab 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -518,6 +518,18 @@ pub async fn ptt_descriptor() -> (String, String, String) { .unwrap_or_else(|_| (String::new(), String::new(), String::new())) } +/// Return the persisted PTT binding as a +/// `(input_class, platform_key)` pair so the UI can hydrate its +/// display state at launch (e.g. show "PTT: Space" next to the +/// badge before the user re-opens the binding dialog). Empty +/// strings mean no binding has been persisted yet. +pub async fn get_ptt_binding() -> (String, String) { + runtime() + .spawn(async { session().get_ptt_binding().await }) + .await + .unwrap_or_else(|_| (String::new(), String::new())) +} + /// Move our own client to `channel_id`. Optional channel password /// for password-protected channels — pass an empty string when not /// required. diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 0a56564..5aeb31c 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 651188866; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1306308591; // Section: executor @@ -326,6 +326,41 @@ fn wire__crate__api__export_diagnostics_impl( }, ) } +fn wire__crate__api__get_ptt_binding_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_ptt_binding", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, ()>( + (move || async move { + let output_ok = Result::<_, ()>::Ok(crate::api::get_ptt_binding().await)?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__get_release_tail_ms_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1406,6 +1441,15 @@ impl SseDecode for Vec { } } +impl SseDecode for (String, String) { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_field0 = ::sse_decode(deserializer); + let mut var_field1 = ::sse_decode(deserializer); + return (var_field0, var_field1); + } +} + impl SseDecode for (String, String, String) { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1458,25 +1502,26 @@ fn pde_ffi_dispatcher_primary_impl( 5 => wire__crate__api__delete_bookmark_impl(port, ptr, rust_vec_len, data_len), 6 => wire__crate__api__disconnect_impl(port, ptr, rust_vec_len, data_len), 7 => wire__crate__api__events_stream_impl(port, ptr, rust_vec_len, data_len), - 9 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), - 10 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len), - 11 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), - 12 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), - 13 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), - 15 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), - 16 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), - 17 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), - 18 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), - 20 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), - 21 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), - 22 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), - 23 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), - 24 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), - 25 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), - 26 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), - 27 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), - 28 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), - 29 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), + 9 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), + 11 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len), + 12 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), + 13 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), + 14 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), + 16 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), + 17 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), + 18 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), + 19 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), + 21 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), + 22 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), + 23 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), + 24 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), + 25 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), + 26 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), + 27 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), + 28 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), + 29 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), + 30 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -1490,8 +1535,8 @@ fn pde_ffi_dispatcher_sync_impl( // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { 8 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len), - 14 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len), - 19 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), + 15 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len), + 20 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -2048,6 +2093,14 @@ impl SseEncode for Vec { } } +impl SseEncode for (String, String) { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.0, serializer); + ::sse_encode(self.1, serializer); + } +} + impl SseEncode for (String, String, String) { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/crates/chanora_storage/src/lib.rs b/crates/chanora_storage/src/lib.rs index 113bfea..ec546bd 100644 --- a/crates/chanora_storage/src/lib.rs +++ b/crates/chanora_storage/src/lib.rs @@ -102,6 +102,20 @@ struct AudioMeta { /// Release-tail in milliseconds (SDD-096). Default 200. #[serde(default = "default_release_tail_ms")] release_tail_ms: u32, + /// Bound PTT input class as the privacy-safe string accepted by + /// the bridge (`""`, `"keyboard"`, `"mouse-side-button"`). + /// Default empty (no binding). + #[serde(default)] + ptt_input_class: String, + /// Opaque platform key string the binding dialog produced + /// (e.g. `"Space"`, `"F10"`, `"mouse-side-button:8"`). Default + /// empty. + #[serde(default)] + ptt_platform_key: String, + /// Display-only platform-neutral key label the UI shows next + /// to the binding (e.g. `"Space"`). Default empty. + #[serde(default)] + ptt_key_label: String, } fn default_release_tail_ms() -> u32 { @@ -113,6 +127,9 @@ impl Default for AudioMeta { Self { transmit_mode: 0, release_tail_ms: 200, + ptt_input_class: String::new(), + ptt_platform_key: String::new(), + ptt_key_label: String::new(), } } } @@ -505,6 +522,35 @@ impl IdentityFileStore { self.read_meta().release_tail_ms } + /// Persist the user's PTT binding (SDD-094 follow-up). The + /// three fields together carry the privacy-safe binding + /// surface — `input_class` is a stable category string + /// (`""`, `"keyboard"`, `"mouse-side-button"`), `platform_key` + /// is the opaque key identifier the platform backend + /// understands, and `key_label` is the display string the UI + /// renders next to the binding. None of these are raw key + /// codes or scan codes per DEC-027. + pub fn set_ptt_binding( + &self, + input_class: &str, + platform_key: &str, + key_label: &str, + ) -> Result<(), StorageError> { + let mut m = self.read_meta(); + m.ptt_input_class = input_class.to_string(); + m.ptt_platform_key = platform_key.to_string(); + m.ptt_key_label = key_label.to_string(); + self.write_meta(&m) + } + + /// Read the persisted PTT binding. Returns + /// `(input_class, platform_key, key_label)` with empty strings + /// meaning "no binding". + pub fn get_ptt_binding(&self) -> (String, String, String) { + let m = self.read_meta(); + (m.ptt_input_class, m.ptt_platform_key, m.ptt_key_label) + } + /// Remove any persisted identity. No-op if none exists. Leaves /// the DEK in place so future saves don't generate a new one. pub fn clear(&self) -> Result<(), StorageError> {