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:
Edison Jwa
2026-06-03 18:20:33 +09:00
committed by GitHub
parent 2c3c3873dc
commit 808324f374
14 changed files with 1781 additions and 434 deletions
+117 -85
View File
@@ -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) => {