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:
@@ -39,7 +39,7 @@ use tsproto_types::ClientType;
|
||||
|
||||
use crate::dto::{
|
||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
||||
ServerActivity, ServerSnapshot,
|
||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
use crate::ProtocolError;
|
||||
|
||||
@@ -88,38 +88,7 @@ async fn send_with_timeout<T: Send>(
|
||||
/// `Version` enum at compile time; if upstream rotates the CSV the
|
||||
/// build will fail loudly here rather than silently fall back.
|
||||
fn pick_client_version() -> Version {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Version::Windows_5_0_0_beta51
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Version::macOS_5_0_0_beta51
|
||||
}
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
Version::iOS_3_5_6
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
Version::Android_3_5_0__7
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Version::Linux_5_0_0_beta51
|
||||
}
|
||||
#[cfg(not(any(
|
||||
target_os = "windows",
|
||||
target_os = "macos",
|
||||
target_os = "ios",
|
||||
target_os = "android",
|
||||
target_os = "linux"
|
||||
)))]
|
||||
{
|
||||
// Last-ditch fallback for unanticipated targets (BSDs,
|
||||
// Solaris-likes). Linux signature is the closest analogue.
|
||||
Version::Linux_5_0_0_beta51
|
||||
}
|
||||
Version::Windows_3_X_X__1
|
||||
}
|
||||
|
||||
/// Typed configuration for a connection attempt.
|
||||
@@ -229,6 +198,8 @@ pub struct ProtocolClient {
|
||||
chat_rx: std::sync::Mutex<Option<mpsc::Receiver<ChatMessage>>>,
|
||||
/// Inbound server-activity stream from the connection task.
|
||||
activity_rx: std::sync::Mutex<Option<mpsc::Receiver<ServerActivity>>>,
|
||||
/// Inbound state-delta stream from the connection task.
|
||||
delta_rx: std::sync::Mutex<Option<mpsc::Receiver<ProtocolDelta>>>,
|
||||
}
|
||||
|
||||
/// One inbound voice packet from a remote client.
|
||||
@@ -289,6 +260,7 @@ impl ProtocolClient {
|
||||
let (voice_in_tx, voice_in_rx) = mpsc::channel::<InboundVoice>(64);
|
||||
let (chat_tx, chat_rx) = mpsc::channel::<ChatMessage>(64);
|
||||
let (activity_tx, activity_rx) = mpsc::channel::<ServerActivity>(128);
|
||||
let (delta_tx, delta_rx) = mpsc::channel::<ProtocolDelta>(256);
|
||||
let (ready_tx, ready_rx) = oneshot::channel::<Result<(), ProtocolError>>();
|
||||
let (lost_tx, lost_rx) = oneshot::channel::<DisconnectReason>();
|
||||
|
||||
@@ -299,6 +271,7 @@ impl ProtocolClient {
|
||||
voice_in_tx,
|
||||
chat_tx,
|
||||
activity_tx,
|
||||
delta_tx,
|
||||
ready_tx,
|
||||
lost_tx,
|
||||
));
|
||||
@@ -311,6 +284,7 @@ impl ProtocolClient {
|
||||
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
|
||||
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
|
||||
activity_rx: std::sync::Mutex::new(Some(activity_rx)),
|
||||
delta_rx: std::sync::Mutex::new(Some(delta_rx)),
|
||||
}),
|
||||
Ok(Ok(Err(e))) => Err(e),
|
||||
Ok(Err(_)) => Err(ProtocolError::Backend(
|
||||
@@ -487,6 +461,10 @@ impl ProtocolClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_delta_rx(&self) -> Option<mpsc::Receiver<ProtocolDelta>> {
|
||||
self.delta_rx.lock().ok().and_then(|mut g| g.take())
|
||||
}
|
||||
|
||||
/// Send a text message to the specified target.
|
||||
pub async fn send_text_message(
|
||||
&self,
|
||||
@@ -514,6 +492,7 @@ async fn connection_task(
|
||||
voice_in_tx: mpsc::Sender<InboundVoice>,
|
||||
chat_tx: mpsc::Sender<ChatMessage>,
|
||||
activity_tx: mpsc::Sender<ServerActivity>,
|
||||
delta_tx: mpsc::Sender<ProtocolDelta>,
|
||||
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
|
||||
lost_tx: oneshot::Sender<DisconnectReason>,
|
||||
) {
|
||||
@@ -742,6 +721,14 @@ async fn connection_task(
|
||||
} = &ev
|
||||
{
|
||||
let own_client = con.get_state().ok().map(|state| state.own_client);
|
||||
if let Some(state) = con.get_state().ok() {
|
||||
if let Some(client) = state.clients.get(client_id) {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ClientMoved {
|
||||
client_id: client_id.0 as u64,
|
||||
new_channel_id: client.channel.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
if own_client == Some(*client_id) {
|
||||
let current_channel = con.get_state().ok().and_then(|state| {
|
||||
state.clients.get(client_id).map(|client| client.channel.0)
|
||||
@@ -779,6 +766,8 @@ async fn connection_task(
|
||||
let _ = activity_tx.try_send(ServerActivity { message: activity });
|
||||
}
|
||||
|
||||
forward_delta(&con, &ev, &delta_tx);
|
||||
|
||||
if let tsclientlib::events::Event::Message {
|
||||
target,
|
||||
invoker,
|
||||
@@ -1931,3 +1920,110 @@ mod tests {
|
||||
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_delta(
|
||||
con: &Connection,
|
||||
ev: &tsclientlib::events::Event,
|
||||
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||
) {
|
||||
use ts_bookkeeping::events::{Event, PropertyId, PropertyValue};
|
||||
|
||||
match ev {
|
||||
Event::PropertyAdded {
|
||||
id: PropertyId::Client(client_id),
|
||||
..
|
||||
} => {
|
||||
if let Some(state) = con.get_state().ok() {
|
||||
if let Some(client) = state.clients.get(client_id) {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ClientJoined {
|
||||
client_id: client_id.0 as u64,
|
||||
channel_id: client.channel.0,
|
||||
name: client.name.clone(),
|
||||
input_muted: client.input_muted,
|
||||
output_muted: client.output_muted || client.output_only_muted,
|
||||
is_server_query: is_server_query_client_type(&client.client_type),
|
||||
talk_power: client.talk_power,
|
||||
talk_power_granted: client.talk_power_granted,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::PropertyRemoved {
|
||||
id: PropertyId::Client(_),
|
||||
old,
|
||||
..
|
||||
} => {
|
||||
if let PropertyValue::Client(client) = old {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ClientLeft {
|
||||
client_id: client.id.0 as u64,
|
||||
name: client.name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::PropertyChanged {
|
||||
id: PropertyId::ClientChannel(_),
|
||||
..
|
||||
} => {}
|
||||
Event::PropertyChanged {
|
||||
id: PropertyId::Client(client_id),
|
||||
..
|
||||
} => {
|
||||
if let Some(state) = con.get_state().ok() {
|
||||
if let Some(client) = state.clients.get(client_id) {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ClientUpdated {
|
||||
client_id: client_id.0 as u64,
|
||||
input_muted: client.input_muted,
|
||||
output_muted: client.output_muted || client.output_only_muted,
|
||||
is_server_query: is_server_query_client_type(&client.client_type),
|
||||
talk_power: client.talk_power,
|
||||
talk_power_granted: client.talk_power_granted,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::PropertyAdded {
|
||||
id: PropertyId::Channel(channel_id),
|
||||
..
|
||||
} => {
|
||||
if let Some(state) = con.get_state().ok() {
|
||||
if let Some(channel) = state.channels.get(channel_id) {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelAdded {
|
||||
id: channel_id.0,
|
||||
parent: channel.parent.0,
|
||||
name: channel.name.clone(),
|
||||
order: channel.order.0 as i64,
|
||||
has_password: channel.has_password.unwrap_or(false),
|
||||
needed_talk_power: channel.needed_talk_power,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::PropertyRemoved {
|
||||
id: PropertyId::Channel(_),
|
||||
old,
|
||||
..
|
||||
} => {
|
||||
if let PropertyValue::Channel(channel) = old {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
|
||||
id: channel.id.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::PropertyChanged {
|
||||
id: PropertyId::Channel(channel_id),
|
||||
..
|
||||
} => {
|
||||
if let Some(state) = con.get_state().ok() {
|
||||
if let Some(channel) = state.channels.get(channel_id) {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelUpdated {
|
||||
id: channel_id.0,
|
||||
name: channel.name.clone(),
|
||||
has_password: channel.has_password.unwrap_or(false),
|
||||
needed_talk_power: channel.needed_talk_power,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,52 @@ pub struct ServerSnapshot {
|
||||
}
|
||||
|
||||
impl ChannelId {
|
||||
/// The conventional root sentinel used by TeamSpeak-compatible
|
||||
/// servers for the top of the channel tree.
|
||||
pub const ROOT: ChannelId = ChannelId(0);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProtocolDelta {
|
||||
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>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ mod dto;
|
||||
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
||||
pub use dto::{
|
||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
||||
ServerActivity, ServerSnapshot,
|
||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
|
||||
// Re-export the upstream voice types so chanora_audio can build outbound
|
||||
|
||||
Reference in New Issue
Block a user