feat: event-driven UI updates for instant channel switching (#15)
* chore: regenerate Cargo.lock after rebase * fix(ui): add 1s cool-down to prevent double-tap channel join voiceJoin returns instantly (fire-and-forget protocol), so the pending-join guard clears before a second tap lands. The cool-down prevents the rapid channel oscillation and ClientIsFlooding (524) that results from double-tapping. * fix(ui): handle ChannelAlreadyIn as success, ClientIsFlooding with backoff - ChannelAlreadyIn (0x0302): treat as silent success, update UI state - ClientIsFlooding (0x020c): show localized snackbar, extend cooldown 5s - Add l10n strings for flooding error (en + zh) * fix(proto): use Windows TS3 client version for broadest compatibility Matches Qint's default (Windows_3_X_X__1). Avoids server-side behavioral differences with TS5 version strings. * fix(proto): patch tsproto-types to handle short P-256 coordinates BigInt::to_bytes_be() strips leading zeros, causing WrongPublicKeyLength when a server's ephemeral key coordinate starts with 0x00. Patch from EdisonJwa/tsclientlib fix/p256-short-coordinate-pad branch left-pads coordinates to the P-256 field size instead of rejecting them. * refactor(core): stop watchdog from emitting SnapshotChanged The watchdog now serves only as a liveness probe (miss counting for reconnection). UI updates are handled entirely by the event-driven delta path (ProtocolDelta → SessionEvent → BridgeEvent → Flutter). Removes signature tracking and SnapshotChanged emission from the supervisor loop. The initial snapshot is still fetched via the Connected event handler in Flutter. * refactor(ui): remove channel-join cooldown guard With event-driven deltas the UI updates instantly on channel moves, so the 1-second cooldown is no longer needed. Double-taps are handled by the server (ChannelAlreadyIn → success) and the pending-channel-id guard prevents overlapping requests. Also removes the _lastJoinCompletedAt field entirely. * fix(core): reattach event forwarders after reconnect The reconnect path swapped in a new ProtocolClient but never took chat_rx, activity_rx, or delta_rx from it. After the first reconnect, the event-driven UI pipeline was dead. Fix by extracting spawn_event_forwarders() helper called on both initial connect and reconnect. Also replaces lossy try_recv+sleep polling with proper recv().await for push-based delivery. * feat(protocol): enrich delta schema with all snapshot-visible fields ClientJoined now carries input_muted, output_muted, is_server_query, talk_power, talk_power_granted. ChannelAdded/ChannelUpdated now carry has_password and needed_talk_power. ClientUpdated also carries is_server_query, talk_power, talk_power_granted. This prevents local snapshot drift where fabricated defaults could hide password requirements, talk-power restrictions, or client type. * refactor: remove dead SnapshotChanged variant end-to-end SnapshotChanged is no longer emitted since the watchdog was refactored to liveness-only. Removes the variant from SessionEvent, BridgeEvent, and the Flutter switch statement. FRB bindings regenerated. * fix(ci): regenerate license inventory and fix iOS submodule fetch - Regenerate docs/security/license-inventory.md to match current lockfile - Remove submodules: true from checkout (causes hard fail on private submodule) - Add explicit git submodule update --init --depth=1 with || true fallback - Check silero-coreml/Package.swift instead of directory existence
This commit is contained in:
+117
-85
@@ -182,16 +182,6 @@ pub enum SessionEvent {
|
||||
/// Audio engine stopped (e.g. before a reconnect cycle, or by
|
||||
/// explicit user action).
|
||||
AudioStopped,
|
||||
/// A snapshot probe observed a change in channel or client
|
||||
/// counts. Useful for UI auto-refresh without active polling
|
||||
/// from the Dart side. Carries the counts so subscribers can
|
||||
/// decide whether to re-fetch.
|
||||
SnapshotChanged {
|
||||
/// Number of channels in the latest probe snapshot.
|
||||
channels: u32,
|
||||
/// 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
|
||||
@@ -263,9 +253,51 @@ pub enum SessionEvent {
|
||||
},
|
||||
/// Audio route changed (speaker/earpiece/BT/wired headset).
|
||||
AudioRouteChanged {
|
||||
/// New audio route.
|
||||
route: AudioRoute,
|
||||
},
|
||||
ClientMoved {
|
||||
client_id: u64,
|
||||
new_channel_id: u64,
|
||||
},
|
||||
ClientJoined {
|
||||
client_id: u64,
|
||||
channel_id: u64,
|
||||
name: String,
|
||||
input_muted: bool,
|
||||
output_muted: bool,
|
||||
is_server_query: bool,
|
||||
talk_power: i32,
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
ClientLeft {
|
||||
client_id: u64,
|
||||
name: String,
|
||||
},
|
||||
ClientUpdated {
|
||||
client_id: u64,
|
||||
input_muted: bool,
|
||||
output_muted: bool,
|
||||
is_server_query: bool,
|
||||
talk_power: i32,
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
ChannelAdded {
|
||||
id: u64,
|
||||
parent: u64,
|
||||
name: String,
|
||||
order: i64,
|
||||
has_password: bool,
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
ChannelRemoved {
|
||||
id: u64,
|
||||
},
|
||||
ChannelUpdated {
|
||||
id: u64,
|
||||
name: String,
|
||||
has_password: bool,
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of channel-join projection sync state.
|
||||
@@ -802,51 +834,7 @@ impl ChanoraSession {
|
||||
}
|
||||
}
|
||||
|
||||
// Forward inbound chat messages from the protocol adapter
|
||||
// to the event broadcast stream. Chat is infrequent (~human
|
||||
// typing rate), so a simple loop with try_recv + yield is fine.
|
||||
if let Some(chat_rx) = client.take_chat_rx() {
|
||||
let ev_tx = self.events_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::time::{sleep, Duration};
|
||||
let mut rx = chat_rx;
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(msg) => {
|
||||
let _ = ev_tx.send(SessionEvent::ChatMessage {
|
||||
sender_id: msg.sender_id.0,
|
||||
sender_name: msg.sender_name,
|
||||
message: msg.message,
|
||||
target: msg.target,
|
||||
});
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(activity_rx) = client.take_activity_rx() {
|
||||
let ev_tx = self.events_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::time::{sleep, Duration};
|
||||
let mut rx = activity_rx;
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(ServerActivity { message }) => {
|
||||
let _ = ev_tx.send(SessionEvent::ServerActivity { message });
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
spawn_event_forwarders(&client, &self.events_tx);
|
||||
|
||||
*guard = Some(ConnectedState {
|
||||
protocol: client,
|
||||
@@ -1925,6 +1913,71 @@ struct SupervisorContext {
|
||||
next_connection_epoch: Arc<Mutex<u64>>,
|
||||
}
|
||||
|
||||
fn spawn_event_forwarders(
|
||||
client: &chanora_protocol::ProtocolClient,
|
||||
events_tx: &broadcast::Sender<SessionEvent>,
|
||||
) {
|
||||
use chanora_protocol::ProtocolDelta;
|
||||
|
||||
if let Some(chat_rx) = client.take_chat_rx() {
|
||||
let ev_tx = events_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = chat_rx;
|
||||
while let Some(msg) = rx.recv().await {
|
||||
let _ = ev_tx.send(SessionEvent::ChatMessage {
|
||||
sender_id: msg.sender_id.0,
|
||||
sender_name: msg.sender_name,
|
||||
message: msg.message,
|
||||
target: msg.target,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(activity_rx) = client.take_activity_rx() {
|
||||
let ev_tx = events_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = activity_rx;
|
||||
while let Some(chanora_protocol::ServerActivity { message }) = rx.recv().await {
|
||||
let _ = ev_tx.send(SessionEvent::ServerActivity { message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(delta_rx) = client.take_delta_rx() {
|
||||
let ev_tx = events_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = delta_rx;
|
||||
while let Some(delta) = rx.recv().await {
|
||||
let event = match delta {
|
||||
ProtocolDelta::ClientMoved { client_id, new_channel_id } => {
|
||||
SessionEvent::ClientMoved { client_id, new_channel_id }
|
||||
}
|
||||
ProtocolDelta::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
ProtocolDelta::ClientLeft { client_id, name } => {
|
||||
SessionEvent::ClientLeft { client_id, name }
|
||||
}
|
||||
ProtocolDelta::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
ProtocolDelta::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
||||
SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
||||
}
|
||||
ProtocolDelta::ChannelRemoved { id } => {
|
||||
SessionEvent::ChannelRemoved { id }
|
||||
}
|
||||
ProtocolDelta::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
||||
SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
||||
}
|
||||
};
|
||||
let _ = ev_tx.send(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
let SupervisorContext {
|
||||
state_arc,
|
||||
@@ -1945,13 +1998,6 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
let mut lost_rx = initial_lost_rx;
|
||||
let mut probe = initial_probe;
|
||||
let mut cfg = initial_cfg;
|
||||
// Stable content hash of the last snapshot observed by the
|
||||
// watchdog. Used to emit `SessionEvent::SnapshotChanged`
|
||||
// whenever the channel or client list mutates in any way —
|
||||
// count, ordering, names, or per-client channel membership.
|
||||
// Stored as a u64 so the comparison is cheap and the field
|
||||
// doesn't grow with the snapshot.
|
||||
let mut last_signature: Option<u64> = None;
|
||||
|
||||
loop {
|
||||
// Watch the current connection: race the protocol task's
|
||||
@@ -2018,7 +2064,7 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
}
|
||||
_ = watchdog.tick() => {
|
||||
match tokio::time::timeout(WATCHDOG_PROBE_TIMEOUT, probe.probe()).await {
|
||||
Ok(Ok(snap)) => {
|
||||
Ok(Ok(_)) => {
|
||||
if misses > 0 {
|
||||
info!(
|
||||
target: "chanora_core",
|
||||
@@ -2027,24 +2073,6 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
);
|
||||
}
|
||||
misses = 0;
|
||||
// A.4 / MVP: emit on any tree change.
|
||||
// Hash channels (id, parent, order, name)
|
||||
// and clients (id, channel, name) so
|
||||
// in-channel moves and renames surface
|
||||
// alongside count changes.
|
||||
let sig = snapshot_signature(&snap);
|
||||
if last_signature != Some(sig) {
|
||||
last_signature = Some(sig);
|
||||
let _ = events_tx.send(SessionEvent::SnapshotChanged {
|
||||
channels: snap.channels.len() as u32,
|
||||
clients: snap.clients.len() as u32,
|
||||
});
|
||||
// Record protocol event (SRS-097).
|
||||
event_recorder.lock().await.record_snapshot_changed(
|
||||
snap.channels.len(),
|
||||
snap.clients.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
misses = misses.saturating_add(1);
|
||||
@@ -2264,6 +2292,13 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
server_name: snap_name,
|
||||
});
|
||||
|
||||
{
|
||||
let guard = state_arc.lock().await;
|
||||
if let Some(state) = guard.as_ref() {
|
||||
spawn_event_forwarders(&state.protocol, &events_tx);
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally restart audio.
|
||||
if restart_audio {
|
||||
let audio_cfg = {
|
||||
@@ -2341,9 +2376,6 @@ async fn supervisor_loop(ctx: SupervisorContext) {
|
||||
// Loop back to waiting for the next loss.
|
||||
lost_rx = new_lost_rx;
|
||||
probe = new_probe;
|
||||
// Force re-emission of SnapshotChanged
|
||||
// for the freshly reconnected session.
|
||||
last_signature = None;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user