diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index d473c88..bc2dc34 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -373,6 +373,7 @@ class _BetaHomeState extends State<_BetaHome> { } } + // ignore: unused_element Future _onLeaveVoice() async { try { await rust.voiceLeave(); @@ -867,7 +868,6 @@ class _BetaHomeState extends State<_BetaHome> { onToggleMute: _onToggleHardMute, onToggleOutputMute: _toggleOutputMute, onConfigure: _onOpenVoiceSettings, - onLeave: _onLeaveVoice, ), const SizedBox(height: 12), Expanded( diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 071670b..80ca2e6 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -475,6 +475,11 @@ class BridgeSnapshot { /// Clients currently known. final List clients; + /// Our own client id. Useful for the UI to highlight our row + /// in the client list and to know which channel we are in + /// without trusting the optimistic local state. + final BigInt ownClientId; + const BridgeSnapshot({ required this.serverName, required this.welcomeMessage, @@ -482,6 +487,7 @@ class BridgeSnapshot { required this.version, required this.channels, required this.clients, + required this.ownClientId, }); @override @@ -491,7 +497,8 @@ class BridgeSnapshot { platform.hashCode ^ version.hashCode ^ channels.hashCode ^ - clients.hashCode; + clients.hashCode ^ + ownClientId.hashCode; @override bool operator ==(Object other) => @@ -503,7 +510,8 @@ class BridgeSnapshot { platform == other.platform && version == other.version && channels == other.channels && - clients == other.clients; + clients == other.clients && + ownClientId == other.ownClientId; } /// Voice transmit mode mirror (SDD-095). Schema-controlled enum; diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 14ad68e..762954b 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -1172,8 +1172,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { BridgeSnapshot dco_decode_bridge_snapshot(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 6) - throw Exception('unexpected arr length: expect 6 but see ${arr.length}'); + if (arr.length != 7) + throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); return BridgeSnapshot( serverName: dco_decode_String(arr[0]), welcomeMessage: dco_decode_String(arr[1]), @@ -1181,6 +1181,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { version: dco_decode_String(arr[3]), channels: dco_decode_list_bridge_channel(arr[4]), clients: dco_decode_list_bridge_client(arr[5]), + ownClientId: dco_decode_u_64(arr[6]), ); } @@ -1483,6 +1484,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_version = sse_decode_String(deserializer); var var_channels = sse_decode_list_bridge_channel(deserializer); var var_clients = sse_decode_list_bridge_client(deserializer); + var var_ownClientId = sse_decode_u_64(deserializer); return BridgeSnapshot( serverName: var_serverName, welcomeMessage: var_welcomeMessage, @@ -1490,6 +1492,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { version: var_version, channels: var_channels, clients: var_clients, + ownClientId: var_ownClientId, ); } @@ -1809,6 +1812,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(self.version, serializer); sse_encode_list_bridge_channel(self.channels, serializer); sse_encode_list_bridge_client(self.clients, serializer); + sse_encode_u_64(self.ownClientId, serializer); } @protected diff --git a/apps/chanora_flutter/lib/widgets/voice_bar.dart b/apps/chanora_flutter/lib/widgets/voice_bar.dart index 585e723..27e8f18 100644 --- a/apps/chanora_flutter/lib/widgets/voice_bar.dart +++ b/apps/chanora_flutter/lib/widgets/voice_bar.dart @@ -29,7 +29,6 @@ class VoiceBar extends StatelessWidget { required this.onToggleMute, required this.onToggleOutputMute, required this.onConfigure, - required this.onLeave, }); /// True when the session is currently joined to a voice channel. @@ -85,9 +84,6 @@ class VoiceBar extends StatelessWidget { /// and intentionally does NOT have its own configure affordance. final VoidCallback onConfigure; - /// Leave the voice channel. - final VoidCallback onLeave; - String _modeLabel(AppL10n l10n) { switch (transmitMode) { case rust.BridgeTransmitMode.ptt: @@ -236,17 +232,12 @@ class VoiceBar extends StatelessWidget { backendId: pttBackendId, boundInputClass: pttBoundInputClass, ), - if (inChannel) ...[ - const SizedBox(height: 6), - Align( - alignment: AlignmentDirectional.centerEnd, - child: TextButton.icon( - icon: const Icon(Icons.call_end), - label: Text(l10n.voiceLeaveAction), - onPressed: onLeave, - ), - ), - ], + // Leave-voice button intentionally absent: TeamSpeak's + // model is "user is always in some channel", not + // Discord's join/leave-voice. To stop being heard / + // hearing others, mute mic and/or speaker via the + // icons at the top of the bar. To physically move, + // tap a different channel in the tree below. ], ), ), diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 84924bf..fd4fb44 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -785,6 +785,18 @@ impl ChanoraSession { audio.set_output_muted(muted); } } + // When server-side input mute is engaged we must ALSO stop + // producing outbound voice frames locally — otherwise the + // Opus encoder happily writes packets, the protocol layer + // hands them to tsclientlib, tsclientlib refuses them + // because its own ClientMuted flag is set, and logs + // "Sending audio while muted" once per 20 ms frame. That + // flooded the log to 200 MB on the Korean test host. + // Clamp the transmit-mode selector's hard_mute input so + // the gate goes false too. + if let Some(muted) = input { + self.voice_selector.set_hard_mute(muted); + } Ok(()) } @@ -855,13 +867,69 @@ impl ChanoraSession { channel_id: u64, password: Option, ) -> 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?; + // 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 + // ~50–200 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. + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(1500); + let confirmed = loop { + if let Ok(snap) = self.snapshot().await { + if let Some((my_id, my_channel)) = self.find_own_in(&snap).await { + if my_channel == channel_id { + info!( + target: "chanora_core", + client_id = my_id, + channel_id, + "voice_join confirmed by snapshot" + ); + break true; + } + } + } + if std::time::Instant::now() >= deadline { + break false; + } + 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(), + ), + )); + } self.voice_selector.set_in_channel(true); self.emit_voice_state(true).await; Ok(()) } + /// Find our own client in a snapshot and return `(client_id, + /// channel_id)`. Used by `voice_join` to confirm the server + /// actually applied a channel move. + async fn find_own_in( + &self, + snap: &chanora_protocol::ServerSnapshot, + ) -> Option<(u64, u64)> { + let own_id = snap.own_client_id; + let me = snap.clients.iter().find(|c| c.id.0 == own_id)?; + Some((me.id.0, me.channel.0)) + } + /// Leave the current voice channel (SDD-094). Marks the /// selector as out-of-channel (which clamps `transmit_active` /// to false), tears down the audio engine, and emits @@ -1472,6 +1540,7 @@ mod tests { channel: chanora_protocol::ChannelId(1), name: "u".into(), }], + own_client_id: 10, }; let mut b = a.clone(); // User moves from channel 1 → 2. Counts unchanged. @@ -1505,6 +1574,7 @@ mod tests { }, ], clients: vec![], + own_client_id: 0, }; let mut b = a.clone(); b.channels.reverse(); diff --git a/crates/chanora_audio/src/ptt_backends/windows.rs b/crates/chanora_audio/src/ptt_backends/windows.rs index e344f36..4b06b6d 100644 --- a/crates/chanora_audio/src/ptt_backends/windows.rs +++ b/crates/chanora_audio/src/ptt_backends/windows.rs @@ -262,9 +262,14 @@ impl DesktopPttBackend for WindowsRawInputBackend { }); }); - let ok = unsafe { run_raw_input_loop() }; + // Run the loop. Pass `init_tx` so the loop can + // signal readiness AFTER RegisterRawInputDevices + // succeeds but BEFORE GetMessageW starts blocking + // — otherwise the main thread's readiness probe + // times out (the signal would only fire when + // WM_QUIT was eventually delivered). + let ok = unsafe { run_raw_input_loop(init_tx) }; armed.store(ok, Ordering::Release); - let _ = init_tx.send(ok); // If init failed, exit immediately. If it // succeeded, run_raw_input_loop already ran the @@ -365,7 +370,20 @@ impl Drop for WindowsRawInputBackend { /// # Safety /// Calls into Win32 directly. Must be invoked on the thread that /// owns the message window (created here). -unsafe fn run_raw_input_loop() -> bool { +unsafe fn run_raw_input_loop(init_tx: std::sync::mpsc::Sender) -> bool { + // Helper that signals the readiness state to the main thread. + // We send exactly once at the first decisive moment (either an + // early-fail return or right after a successful + // RegisterRawInputDevices). Subsequent sends are no-ops. + let mut signal = Some(init_tx); + macro_rules! report { + ($v:expr) => { + if let Some(tx) = signal.take() { + let _ = tx.send($v); + } + }; + } + // Create a hidden message-only window. We need it as the // hwndTarget on the RAWINPUTDEVICE so RIDEV_INPUTSINK delivery // works even when our process has no visible window focus. @@ -377,6 +395,7 @@ unsafe fn run_raw_input_loop() -> bool { error = %e, "windows ptt: GetModuleHandleW failed" ); + report!(false); return false; } }; @@ -415,6 +434,7 @@ unsafe fn run_raw_input_loop() -> bool { target: "chanora_audio", "windows ptt: CreateWindowExW(HWND_MESSAGE) returned null" ); + report!(false); return false; } @@ -445,6 +465,7 @@ unsafe fn run_raw_input_loop() -> bool { target: "chanora_audio", "windows ptt: RegisterRawInputDevices failed" ); + report!(false); return false; } @@ -452,6 +473,11 @@ unsafe fn run_raw_input_loop() -> bool { target: "chanora_audio", "windows ptt: Raw Input devices registered (keyboard + mouse, INPUTSINK)" ); + // Signal readiness NOW (before we block on GetMessageW). The + // main thread's init probe is waiting for this; the loop runs + // until WM_QUIT and the return value at end-of-life is no + // longer used as a readiness signal. + report!(true); // Message pump. GetMessageW returns 0 on WM_QUIT, -1 on error. let mut msg = MSG::default(); @@ -695,9 +721,8 @@ impl DesktopPttBackend for WindowsHookBackend { }); }); - let ok = unsafe { run_hook_loop() }; + let ok = unsafe { run_hook_loop(init_tx) }; armed.store(ok, Ordering::Release); - let _ = init_tx.send(ok); HOOK_CTX.with(|cell| { *cell.borrow_mut() = None; @@ -770,7 +795,16 @@ impl Drop for WindowsHookBackend { /// # Safety /// Calls Win32 directly; must run on the thread that owns the /// hook handles. -unsafe fn run_hook_loop() -> bool { +unsafe fn run_hook_loop(init_tx: std::sync::mpsc::Sender) -> bool { + let mut signal = Some(init_tx); + macro_rules! report { + ($v:expr) => { + if let Some(tx) = signal.take() { + let _ = tx.send($v); + } + }; + } + let h_instance: HMODULE = match GetModuleHandleW(None) { Ok(h) => h, Err(e) => { @@ -779,6 +813,7 @@ unsafe fn run_hook_loop() -> bool { error = %e, "windows ptt: GetModuleHandleW failed (hook)" ); + report!(false); return false; } }; @@ -794,6 +829,7 @@ unsafe fn run_hook_loop() -> bool { error = %e, "windows ptt: SetWindowsHookExW(WH_KEYBOARD_LL) failed" ); + report!(false); return false; } }; @@ -806,6 +842,7 @@ unsafe fn run_hook_loop() -> bool { "windows ptt: SetWindowsHookExW(WH_MOUSE_LL) failed" ); let _ = UnhookWindowsHookEx(kbd_hook); + report!(false); return false; } }; @@ -814,6 +851,10 @@ unsafe fn run_hook_loop() -> bool { target: "chanora_audio", "windows ptt: low-level hooks installed (WH_KEYBOARD_LL + WH_MOUSE_LL)" ); + // Signal readiness now, before blocking on GetMessageW. The + // return value at end-of-loop is no longer used by the init + // probe. + report!(true); let mut msg = MSG::default(); loop { diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 43d2cab..7bf429d 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -55,7 +55,8 @@ fn log_sink() -> &'static chanora_core::InMemoryLogSink { /// `tsproto::resend` and `tsproto::packet_codec` paths that /// flood the diagnostic export during transient packet loss; /// users can still raise verbosity via `RUST_LOG=info`. -const DEFAULT_LOG_FILTER: &str = "info,tsproto::resend=error,tsproto::packet_codec=error"; +const DEFAULT_LOG_FILTER: &str = + "info,tsproto::resend=error,tsproto::packet_codec=error,tsclientlib=error"; /// Initialise the bridge. Must be called once on Dart side before /// any other API call. Sets up panic logging. @@ -193,17 +194,32 @@ fn open_log_file() -> Option { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - // Rotate: if the existing file exceeds 4 MiB rename it to .1 so - // we never grow unbounded. One generation is enough for - // debugging; we are not building a real log-rotation system. - if let Ok(meta) = std::fs::metadata(&path) { - if meta.len() > 4 * 1024 * 1024 { - let _ = std::fs::rename(&path, path.with_extension("log.1")); - } + // Rotate at every launch (not on size). A single session can + // emit huge logs when a chatty subsystem floods (the original + // bug that surfaced this: tsclientlib emitting one warning per + // outbound voice frame while server-side muted). Truncating on + // every launch keeps per-session disk use bounded by what one + // session can produce in its lifetime; the on-launch rotate + // also gives the previous session's log a stable home at + // `chanora.log.1` for post-mortem inspection. + // + // Two generations kept: `chanora.log.1` (previous launch) and + // `chanora.log.2` (the one before that). Older generations + // are deleted to keep disk use bounded across many launches. + if path.exists() { + let g1 = path.with_extension("log.1"); + let g2 = path.with_extension("log.2"); + // .2 is dropped; .1 becomes .2; current becomes .1. + let _ = std::fs::remove_file(&g2); + let _ = std::fs::rename(&g1, &g2); + let _ = std::fs::rename(&path, &g1); } + // Open fresh (truncate if rename somehow failed so we never + // append onto a stale file). std::fs::OpenOptions::new() .create(true) - .append(true) + .write(true) + .truncate(true) .open(&path) .ok() } @@ -251,6 +267,10 @@ pub struct BridgeSnapshot { pub channels: Vec, /// Clients currently known. pub clients: Vec, + /// Our own client id. Useful for the UI to highlight our row + /// in the client list and to know which channel we are in + /// without trusting the optimistic local state. + pub own_client_id: u64, } impl From for BridgeSnapshot { @@ -279,6 +299,7 @@ impl From for BridgeSnapshot { name: c.name, }) .collect(), + own_client_id: s.own_client_id, } } } diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 5aeb31c..22bcabb 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -1348,6 +1348,7 @@ impl SseDecode for crate::api::BridgeSnapshot { let mut var_version = ::sse_decode(deserializer); let mut var_channels = >::sse_decode(deserializer); let mut var_clients = >::sse_decode(deserializer); + let mut var_ownClientId = ::sse_decode(deserializer); return crate::api::BridgeSnapshot { server_name: var_serverName, welcome_message: var_welcomeMessage, @@ -1355,6 +1356,7 @@ impl SseDecode for crate::api::BridgeSnapshot { version: var_version, channels: var_channels, clients: var_clients, + own_client_id: var_ownClientId, }; } } @@ -1769,6 +1771,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeSnapshot { self.version.into_into_dart().into_dart(), self.channels.into_into_dart().into_dart(), self.clients.into_into_dart().into_dart(), + self.own_client_id.into_into_dart().into_dart(), ] .into_dart() } @@ -2012,6 +2015,7 @@ impl SseEncode for crate::api::BridgeSnapshot { ::sse_encode(self.version, serializer); >::sse_encode(self.channels, serializer); >::sse_encode(self.clients, serializer); + ::sse_encode(self.own_client_id, serializer); } } diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index 025c89d..a575773 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -707,6 +707,7 @@ fn build_snapshot(con: &Connection) -> Result { version: sanitize(&state.server.version), channels: channels_dto, clients: clients_dto, + own_client_id: state.own_client.0 as u64, }) } diff --git a/crates/chanora_protocol/src/dto.rs b/crates/chanora_protocol/src/dto.rs index 8a07669..267adda 100644 --- a/crates/chanora_protocol/src/dto.rs +++ b/crates/chanora_protocol/src/dto.rs @@ -52,6 +52,11 @@ pub struct ServerSnapshot { pub channels: Vec, /// All clients currently known. pub clients: Vec, + /// Our own client id as the server published it. Used by the + /// core session to confirm that a `move_to_channel` request + /// actually applied (vs being silently rejected by the + /// server's permission check). + pub own_client_id: u64, } impl ChannelId {