fix(audio,storage,ui): allow PTT binding before audio is running

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<Mutex<Option<PttBinding>>>.
  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.
This commit is contained in:
EdisonJwa
2026-05-15 23:45:20 +08:00
parent 6a41a0b4db
commit 8cd919cffc
9 changed files with 394 additions and 55 deletions
+21
View File
@@ -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<void> _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<void> _reloadBookmarks() async {
@@ -105,6 +105,14 @@ Future<void> 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.
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
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<int> crateApiGetReleaseTailMs();
Future<BridgeTransmitMode> crateApiGetTransmitMode();
@@ -387,7 +389,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
const TaskConstMeta(debugName: "export_diagnostics", argNames: []);
@override
Future<int> 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<int> 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<dynamic>;
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,
@@ -87,6 +87,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@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<RustLibWire> {
@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<RustLibWire> {
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,
@@ -89,6 +89,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@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<RustLibWire> {
@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<RustLibWire> {
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,
+123 -11
View File
@@ -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<ReleaseTailTimer>,
/// 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<Mutex<Option<PttBinding>>>,
}
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<Mutex<SupervisorInner>>,
mut network_rx: watch::Receiver<NetworkState>,
voice_selector: Arc<TransmitModeSelector>,
pending_binding: Arc<Mutex<Option<PttBinding>>>,
) {
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
+12
View File
@@ -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.
+75 -22
View File
@@ -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::SseCodec, _, _, _>(
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<u8> {
}
}
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 = <String>::sse_decode(deserializer);
let mut var_field1 = <String>::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<u8> {
}
}
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) {
<String>::sse_encode(self.0, serializer);
<String>::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) {
+46
View File
@@ -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> {