fix(audio,protocol,ui): TC-2.3 + TC-10 + TC-13 + channel tree hierarchy

Five user-reported defects + one auto-test regression-catcher.

== TC-2.3: capability badge stuck at L0Focused on Korean Win 11 ==
Root cause: in WindowsRawInputBackend::start() (and the parallel
WindowsHookBackend), the worker thread's armed.store(ok, ...) only
ran AFTER GetMessageW returned (i.e. on WM_QUIT). During normal
arming the message pump runs forever, so armed stayed at its
initial false value, and descriptor() reported L0Focused even
though RegisterRawInputDevices had succeeded.

Fix: run_raw_input_loop and run_hook_loop now take armed as a
parameter and flip it to true inside the loop right after the
successful registration, before blocking on GetMessageW. The
outer setter is kept as a belt-and-braces clear-on-failure path.

Also drops the redundant outer 'Raw Input armed' / 'low-level
hook armed' log lines — the in-loop 'Raw Input devices
registered' / 'low-level hooks installed' messages already
convey arming success with full context.

New tests raw_input_backend_start_flips_armed_to_l2 and
hook_backend_start_flips_armed_to_l2 call real start(), sleep
80 ms, assert descriptor().level == L2GlobalHoldToTalk. Replaces
the previous #[ignore]'d real-start smoke test which never
asserted on the descriptor.

== TC-10: no-permission channel rejection invisible ==
Root cause 1: chanora_protocol::adapter::move_self_to used the
fire-and-forget send() on the client_move command. TS3 server
replies with a typed error event the adapter discarded, and
move_to_channel returned Ok regardless.

Root cause 2: even when chanora_core::voice_join detected the
non-confirmation via snapshot polling, the rolled-back error
flowed into the connect-form-area _error string which is hidden
post-connect. The user saw no feedback.

Fix:
* New ProtocolError::ServerRejected { code: u32, message: String }
  carries the canonical TS3 error code per the official catalogue
  at https://github.com/ReSpeak/tsdeclarations (Errors.csv).
* move_self_to now uses send_with_result, returns a MessageHandle.
  The connection-task loop holds a pending_moves HashMap keyed by
  MessageHandle, services StreamItem::MessageResult by looking up
  and resolving the reply with either Ok or the typed
  ServerRejected.
* Pending entries have a 3 s deadline so a server that never
  replies doesn't leak the reply channel — expired entries fall
  back to Ok and let the snapshot poll handle confirmation.
* voice_join short-circuits on ServerRejected (no need for the
  full snapshot poll), still polls for confirmation as a
  belt-and-braces fallback for legacy servers; on poll failure
  emits ServerRejected with sentinel code 0x0001 (undefined).
* New BridgeError::ServerRejected mirror with the same fields;
  CoreError → BridgeError mapping preserves the typed variant.
* Flutter _onJoinChannel shows a floating SnackBar with a
  localised message selected by error code (channelJoinFailed*
  l10n entries). 6 known codes mapped to specific messages
  (insufficient permission, wrong password, channel full,
  family limit, private channel, timeout); everything else
  falls back to the server-supplied generic message.

== TC-13: mouse side-button capture only works on text field ==
The _PttBindingCaptureDialog wrapped its Column with a Listener
using the default HitTestBehavior.deferToChild. Pointer events
landing on the dialog's empty padding regions weren't claimed by
any child and so were never delivered to the Listener.

Fix: explicit HitTestBehavior.opaque so the entire dialog area
catches PointerDown events regardless of where the cursor sits.

== Channel tree hierarchy ==
Reported issue: tree rendered as flat list, no indication of
parent-child nesting. The bridge already carries the
field; the renderer just ignored it.

Fix in _SnapshotView: walk the (already DFS-sorted) channel list
and compute each row's depth from its parent's depth. Render
left-padding of depth * 18 dp. Cap depth at 6 to keep deep
hierarchies visually bounded; the cap plateaus silently (no
glyph, channel still tappable, data carries the real depth).

== Responsive layout ==
Connected layout is now LayoutBuilder-driven. Below 840 dp wide
(Material's tablet/desktop breakpoint) the original stacked
column layout is used (Voice Bar on top, channel tree below).
At 840 dp and above the layout becomes a side-by-side Row with
the Voice Bar pinned at 320 dp on the left and the channel tree
Expanded on the right.

Verification
- cargo check --workspace: clean.
- cargo test --workspace --lib: 80 / 0 / 1 (unchanged Linux total;
  +2 new Windows-only tests not counted here).
- flutter analyze: clean (6 pre-existing Radio.groupValue infos).
- FRB bindings regenerated to expose BridgeError_ServerRejected.
This commit is contained in:
EdisonJwa
2026-05-16 03:27:25 +08:00
parent 0b6ea11077
commit 2511b24982
15 changed files with 644 additions and 112 deletions
+26 -18
View File
@@ -867,20 +867,26 @@ impl ChanoraSession {
channel_id: u64,
password: Option<String>,
) -> Result<(), CoreError> {
// 1. Send the move command. This returns Ok even when the
// server later rejects it with a permission error,
// because the rejection arrives as an asynchronous
// server event the adapter doesn't currently surface.
self.move_to_channel(channel_id, password).await?;
// 1. Send the move command. With send_with_result the
// adapter now correlates against the server's typed
// error reply, so on rejection (no permission, wrong
// password, channel full) we get a concrete
// `ProtocolError::ServerRejected` and don't need the
// snapshot polling at all.
if let Err(e) = self.move_to_channel(channel_id, password).await {
// Roll the selector back so the UI doesn't display a
// fake "joined" state.
self.voice_selector.set_in_channel(false);
self.emit_voice_state(false).await;
return Err(e);
}
// 2. Bring the audio engine up.
self.ensure_audio_running().await?;
// 3. Verify we are actually in the requested channel by
// polling the snapshot for up to 1.5 s. The TS3 server
// typically broadcasts the channel-update within
// ~50200 ms after the move; if we never see ourselves
// move (no-permission, wrong password, channel full,
// etc.) we surface the failure to the caller so the
// Voice Bar doesn't lie about our membership state.
// 3. Belt-and-braces: if the server replied Ok but never
// actually moved us (legacy server, command processed
// but rolled back later, etc.) the snapshot poll catches
// it. Keeps the move/confirm contract honest even when
// upstream tsclientlib changes shape.
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(1500);
let confirmed = loop {
if let Ok(snap) = self.snapshot().await {
@@ -902,15 +908,17 @@ impl ChanoraSession {
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
};
if !confirmed {
// The move did not take effect server-side. Roll
// back the local selector so the UI doesn't display
// a fake "joined" state.
self.voice_selector.set_in_channel(false);
self.emit_voice_state(false).await;
return Err(CoreError::Protocol(
chanora_protocol::ProtocolError::Backend(
"channel move rejected by server (no permission, wrong password, or channel full)".to_string(),
),
chanora_protocol::ProtocolError::ServerRejected {
// Use a sentinel "unknown" code (the canonical
// TS3 error catalogue uses 0x0001 for `undefined`).
code: 0x0001,
message:
"channel move did not take effect server-side within timeout"
.to_string(),
},
));
}
self.voice_selector.set_in_channel(true);