feat(events): A.4 — emit SnapshotChanged from the watchdog probe

Adds a new variant to the lifecycle event catalogue so the UI can
auto-refresh the channel/client tree without an independent polling
timer on the Dart side. The supervisor's existing 5 s snapshot probe
is the source of truth: it already pulls a full snapshot to keep
the watchdog honest, so we piggyback on it.

* `chanora_core::SessionEvent::SnapshotChanged { channels, clients }`
  carries the latest channel and client counts.
* The supervisor compares the probe result to `last_counts` and
  fires the event only when the count actually changes. `last_counts`
  is reset to `None` on a successful reconnect so the freshly
  dialled session re-emits its initial counts.
* `chanora_bridge::api::BridgeEvent::SnapshotChanged` is the
  cross-bridge mirror.
* Flutter routes the event through `_onEvent`, which calls
  `_onRefresh()` to repopulate the snapshot view.

The probe-driven detection has known limits — pure within-channel
client moves do not change the count and so are not surfaced. That
gap will close when the supervisor tracks a content hash in
addition to the count; the count-only signal is sufficient for the
common "someone joined / someone left" case observed on cn.teamspeak.app.
This commit is contained in:
EdisonJwa
2026-05-15 01:27:57 +08:00
parent d2d9ba0a5b
commit 43a3c9ba76
7 changed files with 184 additions and 13 deletions
+12
View File
@@ -359,6 +359,15 @@ pub enum BridgeEvent {
AudioStarted,
/// Audio engine stopped.
AudioStopped,
/// Snapshot probe observed a change in channel/client counts.
/// UI uses this to drive an auto-refresh without active
/// polling.
SnapshotChanged {
/// Latest channel count.
channels: u32,
/// Latest client count.
clients: u32,
},
}
impl From<chanora_core::SessionEvent> for BridgeEvent {
@@ -380,6 +389,9 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
}
chanora_core::SessionEvent::AudioStarted => BridgeEvent::AudioStarted,
chanora_core::SessionEvent::AudioStopped => BridgeEvent::AudioStopped,
chanora_core::SessionEvent::SnapshotChanged { channels, clients } => {
BridgeEvent::SnapshotChanged { channels, clients }
}
}
}
}
@@ -612,6 +612,14 @@ impl SseDecode for crate::api::BridgeEvent {
5 => {
return crate::api::BridgeEvent::AudioStopped;
}
6 => {
let mut var_channels = <u32>::sse_decode(deserializer);
let mut var_clients = <u32>::sse_decode(deserializer);
return crate::api::BridgeEvent::SnapshotChanged {
channels: var_channels,
clients: var_clients,
};
}
_ => {
unimplemented!("");
}
@@ -878,6 +886,12 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
}
crate::api::BridgeEvent::AudioStarted => [4.into_dart()].into_dart(),
crate::api::BridgeEvent::AudioStopped => [5.into_dart()].into_dart(),
crate::api::BridgeEvent::SnapshotChanged { channels, clients } => [
6.into_dart(),
channels.into_into_dart().into_dart(),
clients.into_into_dart().into_dart(),
]
.into_dart(),
_ => {
unimplemented!("");
}
@@ -1055,6 +1069,11 @@ impl SseEncode for crate::api::BridgeEvent {
crate::api::BridgeEvent::AudioStopped => {
<i32>::sse_encode(5, serializer);
}
crate::api::BridgeEvent::SnapshotChanged { channels, clients } => {
<i32>::sse_encode(6, serializer);
<u32>::sse_encode(channels, serializer);
<u32>::sse_encode(clients, serializer);
}
_ => {
unimplemented!("");
}