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
+64 -13
View File
@@ -1612,15 +1612,6 @@ 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,
},
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3,
/// DEC-023..028). The fields carry only privacy-safe values per
/// DEC-027: the capability level, a stable backend identifier,
@@ -1707,9 +1698,51 @@ pub enum BridgeEvent {
},
/// Audio route changed (speaker/earpiece/BT/wired).
AudioRouteChanged {
/// The new audio route.
route: BridgeAudioRoute,
},
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 message target scope.
@@ -1838,9 +1871,6 @@ 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 }
}
chanora_core::SessionEvent::PttCapability {
level,
backend_id,
@@ -1899,6 +1929,27 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
route: route.into(),
}
}
chanora_core::SessionEvent::ClientMoved { client_id, new_channel_id } => {
BridgeEvent::ClientMoved { client_id, new_channel_id }
}
chanora_core::SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
BridgeEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
}
chanora_core::SessionEvent::ClientLeft { client_id, name } => {
BridgeEvent::ClientLeft { client_id, name }
}
chanora_core::SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
BridgeEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
}
chanora_core::SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
BridgeEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
}
chanora_core::SessionEvent::ChannelRemoved { id } => {
BridgeEvent::ChannelRemoved { id }
}
chanora_core::SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power } => {
BridgeEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
}
}
}
}
+271 -39
View File
@@ -2187,14 +2187,6 @@ impl SseDecode for crate::api::BridgeEvent {
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,
};
}
7 => {
let mut var_level = <String>::sse_decode(deserializer);
let mut var_backendId = <String>::sse_decode(deserializer);
let mut var_boundInputClass = <String>::sse_decode(deserializer);
@@ -2204,7 +2196,7 @@ impl SseDecode for crate::api::BridgeEvent {
bound_input_class: var_boundInputClass,
};
}
8 => {
7 => {
let mut var_inChannel = <bool>::sse_decode(deserializer);
let mut var_transmitMode =
<crate::api::BridgeTransmitMode>::sse_decode(deserializer);
@@ -2231,7 +2223,7 @@ impl SseDecode for crate::api::BridgeEvent {
join_error_code: var_joinErrorCode,
};
}
9 => {
8 => {
let mut var_began = <bool>::sse_decode(deserializer);
let mut var_shouldResume = <bool>::sse_decode(deserializer);
return crate::api::BridgeEvent::InterruptionState {
@@ -2239,7 +2231,7 @@ impl SseDecode for crate::api::BridgeEvent {
should_resume: var_shouldResume,
};
}
10 => {
9 => {
let mut var_permission = <String>::sse_decode(deserializer);
let mut var_state = <crate::api::PermissionStateKind>::sse_decode(deserializer);
return crate::api::BridgeEvent::PermissionState {
@@ -2247,7 +2239,7 @@ impl SseDecode for crate::api::BridgeEvent {
state: var_state,
};
}
11 => {
10 => {
let mut var_senderId = <u64>::sse_decode(deserializer);
let mut var_senderName = <String>::sse_decode(deserializer);
let mut var_message = <String>::sse_decode(deserializer);
@@ -2259,16 +2251,100 @@ impl SseDecode for crate::api::BridgeEvent {
target: var_target,
};
}
12 => {
11 => {
let mut var_message = <String>::sse_decode(deserializer);
return crate::api::BridgeEvent::ServerActivity {
message: var_message,
};
}
13 => {
12 => {
let mut var_route = <crate::api::BridgeAudioRoute>::sse_decode(deserializer);
return crate::api::BridgeEvent::AudioRouteChanged { route: var_route };
}
13 => {
let mut var_clientId = <u64>::sse_decode(deserializer);
let mut var_newChannelId = <u64>::sse_decode(deserializer);
return crate::api::BridgeEvent::ClientMoved {
client_id: var_clientId,
new_channel_id: var_newChannelId,
};
}
14 => {
let mut var_clientId = <u64>::sse_decode(deserializer);
let mut var_channelId = <u64>::sse_decode(deserializer);
let mut var_name = <String>::sse_decode(deserializer);
let mut var_inputMuted = <bool>::sse_decode(deserializer);
let mut var_outputMuted = <bool>::sse_decode(deserializer);
let mut var_isServerQuery = <bool>::sse_decode(deserializer);
let mut var_talkPower = <i32>::sse_decode(deserializer);
let mut var_talkPowerGranted = <bool>::sse_decode(deserializer);
return crate::api::BridgeEvent::ClientJoined {
client_id: var_clientId,
channel_id: var_channelId,
name: var_name,
input_muted: var_inputMuted,
output_muted: var_outputMuted,
is_server_query: var_isServerQuery,
talk_power: var_talkPower,
talk_power_granted: var_talkPowerGranted,
};
}
15 => {
let mut var_clientId = <u64>::sse_decode(deserializer);
let mut var_name = <String>::sse_decode(deserializer);
return crate::api::BridgeEvent::ClientLeft {
client_id: var_clientId,
name: var_name,
};
}
16 => {
let mut var_clientId = <u64>::sse_decode(deserializer);
let mut var_inputMuted = <bool>::sse_decode(deserializer);
let mut var_outputMuted = <bool>::sse_decode(deserializer);
let mut var_isServerQuery = <bool>::sse_decode(deserializer);
let mut var_talkPower = <i32>::sse_decode(deserializer);
let mut var_talkPowerGranted = <bool>::sse_decode(deserializer);
return crate::api::BridgeEvent::ClientUpdated {
client_id: var_clientId,
input_muted: var_inputMuted,
output_muted: var_outputMuted,
is_server_query: var_isServerQuery,
talk_power: var_talkPower,
talk_power_granted: var_talkPowerGranted,
};
}
17 => {
let mut var_id = <u64>::sse_decode(deserializer);
let mut var_parent = <u64>::sse_decode(deserializer);
let mut var_name = <String>::sse_decode(deserializer);
let mut var_order = <i64>::sse_decode(deserializer);
let mut var_hasPassword = <bool>::sse_decode(deserializer);
let mut var_neededTalkPower = <Option<i32>>::sse_decode(deserializer);
return crate::api::BridgeEvent::ChannelAdded {
id: var_id,
parent: var_parent,
name: var_name,
order: var_order,
has_password: var_hasPassword,
needed_talk_power: var_neededTalkPower,
};
}
18 => {
let mut var_id = <u64>::sse_decode(deserializer);
return crate::api::BridgeEvent::ChannelRemoved { id: var_id };
}
19 => {
let mut var_id = <u64>::sse_decode(deserializer);
let mut var_name = <String>::sse_decode(deserializer);
let mut var_hasPassword = <bool>::sse_decode(deserializer);
let mut var_neededTalkPower = <Option<i32>>::sse_decode(deserializer);
return crate::api::BridgeEvent::ChannelUpdated {
id: var_id,
name: var_name,
has_password: var_hasPassword,
needed_talk_power: var_neededTalkPower,
};
}
_ => {
unimplemented!("");
}
@@ -3114,18 +3190,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(),
crate::api::BridgeEvent::PttCapability {
level,
backend_id,
bound_input_class,
} => [
7.into_dart(),
6.into_dart(),
level.into_into_dart().into_dart(),
backend_id.into_into_dart().into_dart(),
bound_input_class.into_into_dart().into_dart(),
@@ -3143,7 +3213,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
join_sync_state,
join_error_code,
} => [
8.into_dart(),
7.into_dart(),
in_channel.into_into_dart().into_dart(),
transmit_mode.into_into_dart().into_dart(),
mute.into_into_dart().into_dart(),
@@ -3160,13 +3230,13 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
began,
should_resume,
} => [
9.into_dart(),
8.into_dart(),
began.into_into_dart().into_dart(),
should_resume.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::PermissionState { permission, state } => [
10.into_dart(),
9.into_dart(),
permission.into_into_dart().into_dart(),
state.into_into_dart().into_dart(),
]
@@ -3177,7 +3247,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
message,
target,
} => [
11.into_dart(),
10.into_dart(),
sender_id.into_into_dart().into_dart(),
sender_name.into_into_dart().into_dart(),
message.into_into_dart().into_dart(),
@@ -3185,11 +3255,97 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
]
.into_dart(),
crate::api::BridgeEvent::ServerActivity { message } => {
[12.into_dart(), message.into_into_dart().into_dart()].into_dart()
[11.into_dart(), message.into_into_dart().into_dart()].into_dart()
}
crate::api::BridgeEvent::AudioRouteChanged { route } => {
[13.into_dart(), route.into_into_dart().into_dart()].into_dart()
[12.into_dart(), route.into_into_dart().into_dart()].into_dart()
}
crate::api::BridgeEvent::ClientMoved {
client_id,
new_channel_id,
} => [
13.into_dart(),
client_id.into_into_dart().into_dart(),
new_channel_id.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} => [
14.into_dart(),
client_id.into_into_dart().into_dart(),
channel_id.into_into_dart().into_dart(),
name.into_into_dart().into_dart(),
input_muted.into_into_dart().into_dart(),
output_muted.into_into_dart().into_dart(),
is_server_query.into_into_dart().into_dart(),
talk_power.into_into_dart().into_dart(),
talk_power_granted.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::ClientLeft { client_id, name } => [
15.into_dart(),
client_id.into_into_dart().into_dart(),
name.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::ClientUpdated {
client_id,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} => [
16.into_dart(),
client_id.into_into_dart().into_dart(),
input_muted.into_into_dart().into_dart(),
output_muted.into_into_dart().into_dart(),
is_server_query.into_into_dart().into_dart(),
talk_power.into_into_dart().into_dart(),
talk_power_granted.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
} => [
17.into_dart(),
id.into_into_dart().into_dart(),
parent.into_into_dart().into_dart(),
name.into_into_dart().into_dart(),
order.into_into_dart().into_dart(),
has_password.into_into_dart().into_dart(),
needed_talk_power.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::ChannelRemoved { id } => {
[18.into_dart(), id.into_into_dart().into_dart()].into_dart()
}
crate::api::BridgeEvent::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
} => [
19.into_dart(),
id.into_into_dart().into_dart(),
name.into_into_dart().into_dart(),
has_password.into_into_dart().into_dart(),
needed_talk_power.into_into_dart().into_dart(),
]
.into_dart(),
_ => {
unimplemented!("");
}
@@ -3780,17 +3936,12 @@ 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);
}
crate::api::BridgeEvent::PttCapability {
level,
backend_id,
bound_input_class,
} => {
<i32>::sse_encode(7, serializer);
<i32>::sse_encode(6, serializer);
<String>::sse_encode(level, serializer);
<String>::sse_encode(backend_id, serializer);
<String>::sse_encode(bound_input_class, serializer);
@@ -3807,7 +3958,7 @@ impl SseEncode for crate::api::BridgeEvent {
join_sync_state,
join_error_code,
} => {
<i32>::sse_encode(8, serializer);
<i32>::sse_encode(7, serializer);
<bool>::sse_encode(in_channel, serializer);
<crate::api::BridgeTransmitMode>::sse_encode(transmit_mode, serializer);
<bool>::sse_encode(mute, serializer);
@@ -3826,12 +3977,12 @@ impl SseEncode for crate::api::BridgeEvent {
began,
should_resume,
} => {
<i32>::sse_encode(9, serializer);
<i32>::sse_encode(8, serializer);
<bool>::sse_encode(began, serializer);
<bool>::sse_encode(should_resume, serializer);
}
crate::api::BridgeEvent::PermissionState { permission, state } => {
<i32>::sse_encode(10, serializer);
<i32>::sse_encode(9, serializer);
<String>::sse_encode(permission, serializer);
<crate::api::PermissionStateKind>::sse_encode(state, serializer);
}
@@ -3841,20 +3992,101 @@ impl SseEncode for crate::api::BridgeEvent {
message,
target,
} => {
<i32>::sse_encode(11, serializer);
<i32>::sse_encode(10, serializer);
<u64>::sse_encode(sender_id, serializer);
<String>::sse_encode(sender_name, serializer);
<String>::sse_encode(message, serializer);
<crate::api::BridgeMessageTarget>::sse_encode(target, serializer);
}
crate::api::BridgeEvent::ServerActivity { message } => {
<i32>::sse_encode(12, serializer);
<i32>::sse_encode(11, serializer);
<String>::sse_encode(message, serializer);
}
crate::api::BridgeEvent::AudioRouteChanged { route } => {
<i32>::sse_encode(13, serializer);
<i32>::sse_encode(12, serializer);
<crate::api::BridgeAudioRoute>::sse_encode(route, serializer);
}
crate::api::BridgeEvent::ClientMoved {
client_id,
new_channel_id,
} => {
<i32>::sse_encode(13, serializer);
<u64>::sse_encode(client_id, serializer);
<u64>::sse_encode(new_channel_id, serializer);
}
crate::api::BridgeEvent::ClientJoined {
client_id,
channel_id,
name,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} => {
<i32>::sse_encode(14, serializer);
<u64>::sse_encode(client_id, serializer);
<u64>::sse_encode(channel_id, serializer);
<String>::sse_encode(name, serializer);
<bool>::sse_encode(input_muted, serializer);
<bool>::sse_encode(output_muted, serializer);
<bool>::sse_encode(is_server_query, serializer);
<i32>::sse_encode(talk_power, serializer);
<bool>::sse_encode(talk_power_granted, serializer);
}
crate::api::BridgeEvent::ClientLeft { client_id, name } => {
<i32>::sse_encode(15, serializer);
<u64>::sse_encode(client_id, serializer);
<String>::sse_encode(name, serializer);
}
crate::api::BridgeEvent::ClientUpdated {
client_id,
input_muted,
output_muted,
is_server_query,
talk_power,
talk_power_granted,
} => {
<i32>::sse_encode(16, serializer);
<u64>::sse_encode(client_id, serializer);
<bool>::sse_encode(input_muted, serializer);
<bool>::sse_encode(output_muted, serializer);
<bool>::sse_encode(is_server_query, serializer);
<i32>::sse_encode(talk_power, serializer);
<bool>::sse_encode(talk_power_granted, serializer);
}
crate::api::BridgeEvent::ChannelAdded {
id,
parent,
name,
order,
has_password,
needed_talk_power,
} => {
<i32>::sse_encode(17, serializer);
<u64>::sse_encode(id, serializer);
<u64>::sse_encode(parent, serializer);
<String>::sse_encode(name, serializer);
<i64>::sse_encode(order, serializer);
<bool>::sse_encode(has_password, serializer);
<Option<i32>>::sse_encode(needed_talk_power, serializer);
}
crate::api::BridgeEvent::ChannelRemoved { id } => {
<i32>::sse_encode(18, serializer);
<u64>::sse_encode(id, serializer);
}
crate::api::BridgeEvent::ChannelUpdated {
id,
name,
has_password,
needed_talk_power,
} => {
<i32>::sse_encode(19, serializer);
<u64>::sse_encode(id, serializer);
<String>::sse_encode(name, serializer);
<bool>::sse_encode(has_password, serializer);
<Option<i32>>::sse_encode(needed_talk_power, serializer);
}
_ => {
unimplemented!("");
}