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
+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> {