diff --git a/gui/src/app.rs b/gui/src/app.rs index 7613fc7..60e65ff 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -2,7 +2,7 @@ use dioxus::prelude::*; use mime_guess::Mime; -use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry}; use ordermap::OrderSet; use std::collections::{HashMap, HashSet}; @@ -899,10 +899,44 @@ pub fn LoginView(config: Resource) -> Element { ) } -/// Placeholder component for ping info — will be implemented in a later commit. +/// Displays live ping info (user count, latency) for a server using the mumble UDP ping protocol. #[component] fn ServerPingInfo(address: String, port: u16) -> Element { - rsx!() + let ping_result = use_resource(move || { + let addr = address.clone(); + async move { Platform::ping_server(&addr, port).await } + }); + + let read = ping_result.read(); + match &*read { + Some(Ok(status)) => { + let users_text = match (status.users, status.max_users) { + (Some(u), Some(m)) => format!("{u}/{m}"), + (Some(u), None) => format!("{u} online"), + _ => String::new(), + }; + rsx!( + div { + class: "server-card__ping", + if !users_text.is_empty() { + span { "{users_text}" } + } + } + ) + } + Some(Err(_)) => rsx!( + div { + class: "server-card__ping", + span { "offline" } + } + ), + None => rsx!( + div { + class: "server-card__ping", + span { "..." } + } + ), + } } #[component] diff --git a/gui/src/imp/desktop.rs b/gui/src/imp/desktop.rs index 5220dea..4d746be 100644 --- a/gui/src/imp/desktop.rs +++ b/gui/src/imp/desktop.rs @@ -1,5 +1,5 @@ use crate::app::Command; -use color_eyre::eyre::Error; +use color_eyre::eyre::{bail, Error}; use dioxus::hooks::UnboundedReceiver; use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; @@ -75,6 +75,10 @@ impl super::PlatformInterface for DesktopPlatform { super::connect::get_status(client).await } + async fn ping_server(address: &str, port: u16) -> color_eyre::Result { + mumble_udp_ping(address, port).await + } + fn init_logging() { use tracing::level_filters::LevelFilter; use tracing_subscriber::filter::EnvFilter; @@ -122,3 +126,57 @@ fn save_config_map(config: &HashMap) -> color_eyre::Result<()> { std::fs::write(&config_path, contents)?; Ok(()) } + +/// Mumble UDP ping protocol. +/// +/// Send a 12-byte packet: 4 zero bytes + 8-byte identifier. +/// Receive a 24-byte response: 4 bytes version (1 byte each: major.minor.patch + padding) +/// + 8 bytes identifier echo + 4 bytes current_users + 4 bytes max_users + 4 bytes bandwidth. +async fn mumble_udp_ping(address: &str, port: u16) -> color_eyre::Result { + use std::net::ToSocketAddrs; + use tokio::net::UdpSocket; + + let dest = format!("{}:{}", address, port) + .to_socket_addrs()? + .next() + .ok_or_else(|| color_eyre::eyre::eyre!("could not resolve address"))?; + + let bind_addr = if dest.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" }; + let socket = UdpSocket::bind(bind_addr).await?; + socket.connect(dest).await?; + + // Build ping packet: 4 zero bytes + 8-byte request ID + let request_id: u64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + + let mut buf = [0u8; 12]; + buf[4..12].copy_from_slice(&request_id.to_be_bytes()); + socket.send(&buf).await?; + + let mut response = [0u8; 24]; + let timeout = tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut response)).await; + + match timeout { + Ok(Ok(len)) if len >= 24 => { + let version_major = response[0] as u32; + let version_minor = response[1] as u32; + let version_patch = response[2] as u32; + let users = u32::from_be_bytes([response[12], response[13], response[14], response[15]]); + let max_users = u32::from_be_bytes([response[16], response[17], response[18], response[19]]); + let bandwidth = u32::from_be_bytes([response[20], response[21], response[22], response[23]]); + + Ok(ServerStatus { + success: true, + version: Some((version_major, version_minor, version_patch)), + users: Some(users), + max_users: Some(max_users), + bandwidth: Some(bandwidth), + }) + } + Ok(Ok(_)) => bail!("ping response too short"), + Ok(Err(e)) => Err(e.into()), + Err(_) => bail!("ping timed out"), + } +} diff --git a/gui/src/imp/mobile.rs b/gui/src/imp/mobile.rs index 7837bf3..6464bad 100644 --- a/gui/src/imp/mobile.rs +++ b/gui/src/imp/mobile.rs @@ -54,6 +54,10 @@ impl super::PlatformInterface for MobilePlatform { super::connect::get_status(client).await } + async fn ping_server(_address: &str, _port: u16) -> color_eyre::Result { + color_eyre::eyre::bail!("ping not supported on mobile yet") + } + fn init_logging() { use tracing::level_filters::LevelFilter; use tracing_subscriber::filter::EnvFilter; diff --git a/gui/src/imp/mod.rs b/gui/src/imp/mod.rs index cd36926..8a72548 100644 --- a/gui/src/imp/mod.rs +++ b/gui/src/imp/mod.rs @@ -70,11 +70,17 @@ pub trait PlatformInterface { gui_config: &ClientConfig, ) -> impl Future>; - /// Get server status (user count, version, etc.). + /// Get server status (user count, version, etc.) via the web proxy status endpoint. fn get_status( client: &reqwest::Client, ) -> impl Future>; + /// Ping a mumble server via UDP to get version, user count, etc. + fn ping_server( + address: &str, + port: u16, + ) -> impl Future>; + /// Load the proxy overrides (proxy URL, cert hash, etc.). fn load_config() -> impl Future>; diff --git a/gui/src/imp/stub.rs b/gui/src/imp/stub.rs index 0c0c67a..2a95304 100644 --- a/gui/src/imp/stub.rs +++ b/gui/src/imp/stub.rs @@ -34,6 +34,13 @@ impl super::PlatformInterface for StubPlatform { async { panic!("stubbed platform") } } + fn ping_server( + _address: &str, + _port: u16, + ) -> impl Future> { + async { panic!("stubbed platform") } + } + fn load_config() -> impl Future> { async { panic!("stubbed platform") } } diff --git a/gui/src/imp/web.rs b/gui/src/imp/web.rs index 814b36b..8f83747 100644 --- a/gui/src/imp/web.rs +++ b/gui/src/imp/web.rs @@ -165,6 +165,11 @@ impl super::PlatformInterface for WebPlatform { .await?) } + async fn ping_server(_address: &str, _port: u16) -> color_eyre::Result { + // UDP ping not available in browsers; use get_status via HTTP proxy instead + color_eyre::eyre::bail!("UDP ping not supported on web platform") + } + async fn sleep(duration: Duration) { TimeoutFuture::new(duration.as_millis() as u32).await; }