From 59afbdab7ba01bd15c8a932274fd08036e743dcf Mon Sep 17 00:00:00 2001 From: restitux Date: Tue, 5 May 2026 04:10:05 +0000 Subject: [PATCH] Add server list persistence and UDP ping to platform trait Add ServerEntry model, load_servers/save_servers/set_default_server to PlatformInterface with implementations for desktop (etcetera config), web (localStorage), mobile (stub), and stub platforms. Implement mumble_udp_ping protocol on desktop for direct server status queries. Co-Authored-By: Claude Opus 4.6 --- common/src/lib.rs | 10 ++++ gui/src/imp/desktop.rs | 114 ++++++++++++++++++++++++++++++++++++++++- gui/src/imp/mobile.rs | 16 +++++- gui/src/imp/mod.rs | 17 +++++- gui/src/imp/stub.rs | 21 +++++++- gui/src/imp/web.rs | 29 ++++++++++- 6 files changed, 201 insertions(+), 6 deletions(-) diff --git a/common/src/lib.rs b/common/src/lib.rs index 4821d9a..b1f2908 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -16,3 +16,13 @@ pub struct ServerStatus { pub max_users: Option, pub bandwidth: Option, } + +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)] +pub struct ServerEntry { + pub name: String, + pub address: String, + pub port: u16, + pub username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, +} diff --git a/gui/src/imp/desktop.rs b/gui/src/imp/desktop.rs index 8cff633..858eb1b 100644 --- a/gui/src/imp/desktop.rs +++ b/gui/src/imp/desktop.rs @@ -1,7 +1,9 @@ use crate::app::{Command, SharedState}; -use color_eyre::eyre::Error; +use color_eyre::eyre::{bail, Error}; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; +use mumble_web2_common::{ProxyOverrides, ServerEntry, ServerStatus}; +use std::collections::HashMap; use std::time::Duration; /// Desktop platform implementation using Tokio and native audio. @@ -37,6 +39,32 @@ 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 set_default_server(server: &str) -> Option<()> { + let mut config = load_config_map(); + config.insert("server".to_string(), server.to_string()); + save_config_map(&config).ok() + } + + fn load_servers() -> Vec { + let config = load_config_map(); + config + .get("servers") + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default() + } + + fn save_servers(servers: &[ServerEntry]) { + let mut config = load_config_map(); + if let Ok(json) = serde_json::to_string(servers) { + config.insert("servers".to_string(), json); + let _ = save_config_map(&config); + } + } + fn init_logging() { use tracing::level_filters::LevelFilter; use tracing_subscriber::filter::EnvFilter; @@ -56,3 +84,85 @@ impl super::PlatformInterface for DesktopPlatform { // No-op on desktop } } + +fn get_config_path() -> std::path::PathBuf { + let strategy = choose_app_strategy(AppStrategyArgs { + top_level_domain: "xyz".to_string(), + author: "ohea".to_string(), + app_name: "Mumble Web2".to_string(), + }) + .expect("failed to choose app strategy"); + strategy.config_dir().join("config.json") +} + +fn load_config_map() -> HashMap { + let config_path = get_config_path(); + match std::fs::read_to_string(&config_path) { + Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), + Err(_) => HashMap::new(), + } +} + +fn save_config_map(config: &HashMap) -> color_eyre::Result<()> { + let config_path = get_config_path(); + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string_pretty(config)?; + 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 0a15995..d0ec222 100644 --- a/gui/src/imp/mobile.rs +++ b/gui/src/imp/mobile.rs @@ -1,7 +1,7 @@ use crate::app::{Command, SharedState}; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use mumble_web2_common::{ProxyOverrides, ServerEntry, ServerStatus}; use std::time::Duration; /// Mobile platform implementation using Tokio, native audio, and Android permissions. @@ -33,6 +33,20 @@ 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 set_default_server(_server: &str) -> Option<()> { + None + } + + fn load_servers() -> Vec { + Vec::new() + } + + fn save_servers(_servers: &[ServerEntry]) {} + 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 7504a97..6099232 100644 --- a/gui/src/imp/mod.rs +++ b/gui/src/imp/mod.rs @@ -8,7 +8,7 @@ use crate::app::{Command, SharedState}; use crate::effects::AudioProcessor; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use mumble_web2_common::{ProxyOverrides, ServerEntry, ServerStatus}; use std::collections::HashMap; use std::future::Future; use std::time::Duration; @@ -91,9 +91,24 @@ pub trait PlatformInterface { 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_proxy_overrides() -> impl Future>; + /// Save the default server URL. + fn set_default_server(server: &str) -> Option<()>; + + /// Load the saved server list. + fn load_servers() -> Vec; + + /// Save the server list. + fn save_servers(servers: &[ServerEntry]); + /// Async sleep for the given duration. fn sleep(duration: Duration) -> impl Future; } diff --git a/gui/src/imp/stub.rs b/gui/src/imp/stub.rs index a73aeb3..84b625f 100644 --- a/gui/src/imp/stub.rs +++ b/gui/src/imp/stub.rs @@ -3,7 +3,7 @@ use crate::{app::SharedState, effects::AudioProcessor}; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use mumble_web2_common::{ProxyOverrides, ServerEntry, ServerStatus}; use std::future::Future; pub struct StubPlatform; @@ -36,10 +36,29 @@ impl super::PlatformInterface for StubPlatform { async { panic!("stubbed platform") } } + fn ping_server( + _address: &str, + _port: u16, + ) -> impl Future> { + async { panic!("stubbed platform") } + } + fn load_proxy_overrides() -> impl Future> { async { panic!("stubbed platform") } } + fn set_default_server(_server: &str) -> Option<()> { + panic!("stubbed platform") + } + + fn load_servers() -> Vec { + panic!("stubbed platform") + } + + fn save_servers(_servers: &[ServerEntry]) { + panic!("stubbed platform") + } + fn sleep(_duration: std::time::Duration) -> impl Future { async { panic!("stubbed platform") } } diff --git a/gui/src/imp/web.rs b/gui/src/imp/web.rs index 8ca52e3..303c664 100644 --- a/gui/src/imp/web.rs +++ b/gui/src/imp/web.rs @@ -6,7 +6,7 @@ use dioxus::prelude::*; use gloo_timers::future::TimeoutFuture; use js_sys::Float32Array; use mumble_protocol::control::ClientControlCodec; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use mumble_web2_common::{ProxyOverrides, ServerEntry, ServerStatus}; use reqwest::Url; use std::collections::HashMap; use std::future::Future; @@ -125,6 +125,33 @@ 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") + } + + fn set_default_server(_server: &str) -> Option<()> { + None + } + + fn load_servers() -> Vec { + web_sys::window() + .and_then(|w| w.local_storage().ok()?) + .and_then(|s| s.get_item("servers").ok()?) + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() + } + + fn save_servers(servers: &[ServerEntry]) { + if let Ok(json) = serde_json::to_string(servers) { + if let Some(storage) = web_sys::window() + .and_then(|w| w.local_storage().ok()?) + { + let _ = storage.set_item("servers", &json); + } + } + } + async fn sleep(duration: Duration) { TimeoutFuture::new(duration.as_millis() as u32).await; }