diff --git a/common/src/lib.rs b/common/src/lib.rs index 0d520a5..5845f5d 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/assets/arrow-right-svgrepo-com.svg b/gui/assets/arrow-right-svgrepo-com.svg new file mode 100644 index 0000000..4ae7291 --- /dev/null +++ b/gui/assets/arrow-right-svgrepo-com.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/gui/assets/delete-2-svgrepo-com.svg b/gui/assets/delete-2-svgrepo-com.svg new file mode 100644 index 0000000..27cdd28 --- /dev/null +++ b/gui/assets/delete-2-svgrepo-com.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/gui/assets/earth-14-svgrepo-com.svg b/gui/assets/earth-14-svgrepo-com.svg new file mode 100644 index 0000000..0512760 --- /dev/null +++ b/gui/assets/earth-14-svgrepo-com.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/gui/assets/edit-3-svgrepo-com.svg b/gui/assets/edit-3-svgrepo-com.svg new file mode 100644 index 0000000..7d955aa --- /dev/null +++ b/gui/assets/edit-3-svgrepo-com.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/gui/assets/main.scss b/gui/assets/main.scss index f6054ab..20a0040 100644 --- a/gui/assets/main.scss +++ b/gui/assets/main.scss @@ -431,3 +431,349 @@ a:visited { } } } + +.server-list-page { + display: flex; + flex-direction: column; + padding: 1.5rem; + gap: 1rem; +} + +.server-list-page h1 { + text-align: center; +} + +.login_version { + font-size: 0.55em; + font-weight: 400; + color: rgba(255, 255, 255, 0.4); + vertical-align: middle; +} + +.server-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 500px; + margin: 0 auto; +} + +/* Rounded card */ +.server-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.server-card__icon { + width: 32px; + height: 32px; + opacity: 0.65; + filter: brightness(0) invert(0.8); /* light gray */ + flex-shrink: 0; +} + +.server-card__info { + display: flex; + flex-direction: column; + gap: 0.15rem; + flex: 1; /* pushes the connect button to the far right */ + min-width: 0; /* prevents text overflow from breaking flex layout */ +} + +.server-card__name { + font-weight: 600; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.server-card__address { + font-size: 0.78rem; + opacity: 0.55; +} + + +.server-card__action { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + padding: 0; + line-height: 0; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.07); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} + +.server-card__action img { + width: 20px; + height: 20px; + filter: brightness(0) invert(0.8); /* light gray */ + opacity: 0.75; + transition: opacity 0.15s; +} + +.server-card__action:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.35); + transform: scale(1.08); +} + +.server-card__action:hover img { + opacity: 1.0; +} + +.server-card__action:active { + transform: scale(0.95); +} + +/* Add server — dashed outline style to distinguish from real cards */ +.add-server-btn { + width: 100%; + padding: 0.85rem; + border-radius: 12px; + border: 2px dashed rgba(255, 255, 255, 0.2); + background: transparent; + color: rgba(255, 255, 255, 0.45); + font-size: 0.9rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + width: 500px; + margin: 0 auto; +} + +.add-server-btn:hover { + border-color: rgba(255, 255, 255, 0.4); + color: rgba(255, 255, 255, 0.7); +} + + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.0); + z-index: 999; + animation: backdrop-fade-in 150ms ease-out forwards; +} + +.modal-container { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + pointer-events: none; +} + + +.modal { + pointer-events: auto; + + /* Make this solid or nearly solid instead of see-through */ + /* Old: background: rgba(255, 255, 255, 0.05); */ + background: #141414; /* or #151822, or rgb(15, 15, 20) */ + + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + + padding: 1.25rem 1.5rem 1.4rem; + width: 500px; + max-width: 90vw; + + color: #fff; + display: flex; + flex-direction: column; + gap: 0.9rem; + + opacity: 0; + transform: scale(0.9); + animation: modal-pop-in 160ms ease-out forwards; +} + +.modal h2 { + font-size: 1.05rem; + font-weight: 600; + text-align: left; + margin: 0; +} + +/* Form layout */ + +.modal-field { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.modal-field label { + font-size: 0.8rem; + opacity: 0.7; +} + +.modal-field input { + padding: 0.55rem 0.6rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(0, 0, 0, 0.35); + color: #fff; + font-size: 0.85rem; + outline: none; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.modal-field input::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +.modal-field input:focus { + border-color: rgba(255, 255, 255, 0.55); + background: rgba(0, 0, 0, 0.55); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25); +} + +/* Actions row */ + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.5rem; +} + +/* Secondary button (Cancel) */ + +.modal-btn { + padding: 0.5rem 0.9rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.07); + color: rgba(255, 255, 255, 0.85); + font-size: 0.85rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} + +.modal-btn:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.35); + transform: translateY(-1px); +} + +.modal-btn:active { + transform: translateY(0) scale(0.97); +} + +/* Primary button (Save) */ + +.modal-btn--primary { + background: rgba(67, 156, 255, 0.85); + border-color: rgba(67, 156, 255, 1); + color: #ffffff; +} + +.modal-btn--primary:hover { + background: rgba(92, 174, 255, 0.95); + border-color: rgba(135, 196, 255, 1); +} + +/* Delete button (danger) */ + +.modal-btn--danger { + background: rgba(220, 60, 60, 0.85); + border-color: rgba(220, 60, 60, 1); + color: #ffffff; +} + +.modal-btn--danger:hover { + background: rgba(240, 80, 80, 0.95); + border-color: rgba(255, 120, 120, 1); +} + +.modal-actions__spacer { + flex: 1; +} + +/* Override mode username row */ + +.override-username-row { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 0.75rem 1.25rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.override-username-input { + flex: 1; + padding: 0.55rem 0.6rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(0, 0, 0, 0.35); + color: #fff; + font-size: 0.85rem; + outline: none; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.override-username-input:focus { + border-color: rgba(255, 255, 255, 0.55); + background: rgba(0, 0, 0, 0.55); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25); +} + +.override-username-input::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +/* Connect action button highlight */ + +.server-card__action--connect:hover { + background: rgba(67, 156, 255, 0.3); + border-color: rgba(67, 156, 255, 0.6); +} + +/* Ping info on server card */ + +.server-card__ping { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.1rem; + font-size: 0.75rem; + opacity: 0.6; + flex-shrink: 0; + min-width: 60px; + text-align: right; +} + +/* Keyframes */ + +@keyframes backdrop-fade-in { + from { background: rgba(0, 0, 0, 0.0); } + to { background: rgba(0, 0, 0, 0.4); } +} + +@keyframes modal-pop-in { + from { + opacity: 0; + transform: scale(0.9); + } + to { + opacity: 1; + transform: scale(1.0); + } +} diff --git a/gui/src/app.rs b/gui/src/app.rs index a56e647..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, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry}; use ordermap::OrderSet; use std::collections::{HashMap, HashSet}; @@ -686,173 +686,474 @@ pub fn ServerView(config: Resource) -> Element { pub fn LoginView(config: Resource) -> Element { let net: Coroutine = use_coroutine_handle(); - let last_status = use_signal(|| None::>); - use_resource(move || async move { - let client = reqwest::Client::new(); - loop { - *last_status.write_unchecked() = Some(Platform::get_status(&client).await); - Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await; - } - }); + let mut servers = use_signal(|| Platform::load_servers()); + let mut show_add_modal = use_signal(|| false); + let mut editing_index = use_signal(|| None::); - let mut address_input = use_signal(|| Platform::load_server_url()); - let address = use_memo(move || { - if let Some(addr) = address_input() { - addr.clone() - } else { - config() - .and_then(|c| c.proxy_url.clone()) - .unwrap_or_default() - } - }); + let version = option_env!("MUMBLE_WEB2_VERSION"); - let previous_username = Platform::load_username(); - let mut username = use_signal(|| previous_username.unwrap_or(String::new())); + let is_override_mode = config + .read() + .as_ref() + .is_some_and(|c| !c.any_server); - let do_connect = move |_| { - //let _ = set_default_username(&username.read()); - let _ = Platform::set_default_username(&username.read()); - if config.read().as_ref().is_some_and(|cfg| cfg.any_server) { - Platform::set_default_server(&address.read()); - } - net.send(Connect { - address: address.read().clone(), - username: username.read().clone(), - config: config.read().clone().unwrap_or_default(), - }) - }; - let status = &STATE.status; - let bottom = match &*status.read() { - Disconnected => rsx! { - button { - class: "login_bttn", - onclick: do_connect.clone(), - "Connect" - } - }, - Connecting => rsx! { + // --- Overrides mode: single preset server, username-only input --- + if is_override_mode { + let proxy_url = config + .read() + .as_ref() + .and_then(|c| c.proxy_url.clone()) + .unwrap_or_default(); + + let previous_username = Platform::load_username(); + let mut username = use_signal(|| previous_username.unwrap_or_default()); + + let status = &STATE.status; + let is_connecting = matches!(&*status.read(), Connecting); + + return rsx!( div { - class: "login_bttn", - "Connecting..." - } - }, - Failed(msg) => rsx!( - button { - class: "login_bttn", - onclick: do_connect.clone(), - "Reconnect" - } - div { - class: "login_error", - "Failed to connect:" - pre { - "{msg}" + class: "server-list-page", + h1 { + "Mumble Web" + match version { + Some(v) => rsx!(div { class: "login_version", "({v})" }), + None => rsx!(), + } + } + div { + class: "server-list", + div { + class: "server-card", + img { + class: "server-card__icon", + src: asset!("assets/earth-14-svgrepo-com.svg"), + alt: "Server icon", + } + div { + class: "server-card__info", + span { class: "server-card__name", "Server" } + span { class: "server-card__address", "{proxy_url}" } + } + } + div { + class: "override-username-row", + input { + class: "override-username-input", + r#type: "text", + placeholder: "Username", + value: "{username.read()}", + oninput: move |evt| username.set(evt.value().clone()), + } + button { + class: "server-card__action server-card__action--connect", + disabled: is_connecting || username.read().is_empty(), + onclick: { + let proxy_url = proxy_url.clone(); + move |_| { + let _ = Platform::set_default_username(&username.read()); + net.send(Connect { + address: proxy_url.clone(), + username: username.read().clone(), + config: config.read().clone().unwrap_or_default(), + }); + } + }, + img { + src: asset!("assets/arrow-right-svgrepo-com.svg"), + alt: "Connect", + } + } + } + match &*STATE.status.read() { + Failed(msg) => rsx!( + div { + class: "login_error", + "Failed to connect:" + pre { "{msg}" } + } + ), + _ => rsx!(), + } } } - ), - Connected => unreachable!(), - }; - let version = option_env!("MUMBLE_WEB2_VERSION"); + ); + } + + // --- Normal mode: editable server list --- rsx!( div { - class: "login", + class: "server-list-page", h1 { "Mumble Web" match version { - Some(v) => rsx!(" " span { class: "login_version", "({v})" }), + Some(v) => rsx!(div { class: "login_version", "({v})" }), None => rsx!(), } } - if config.read().as_ref().is_some_and(|cfg| cfg.any_server) { - div { - label { - for: "address-entry", - "Server Address:" - } - input { - id: "address-entry", - placeholder: "address", - value: "{address.read()}", - autofocus: "true", - oninput: move |evt| address_input.set(Some(evt.value().clone())), + div { + class: "server-list", + for (idx, server) in servers.read().iter().enumerate() { + { + let address = format!("{}:{}", server.address, server.port); + let connect_entry = server.clone(); + rsx!( + div { + key: "{idx}", + class: "server-card", + img { + class: "server-card__icon", + src: asset!("assets/earth-14-svgrepo-com.svg"), + alt: "Server icon", + } + div { + class: "server-card__info", + span { class: "server-card__name", "{server.name}" } + span { class: "server-card__address", "{address}" } + } + ServerPingInfo { + address: server.address.clone(), + port: server.port, + } + button { + class: "server-card__action", + onclick: move |_| editing_index.set(Some(idx)), + img { + src: asset!("assets/edit-3-svgrepo-com.svg"), + alt: "Edit", + } + } + button { + class: "server-card__action server-card__action--connect", + onclick: { + let entry = connect_entry.clone(); + move |_| { + let _ = Platform::set_default_username(&entry.username); + let addr = format!("{}:{}", entry.address, entry.port); + net.send(Connect { + address: addr, + username: entry.username.clone(), + config: config.read().clone().unwrap_or_default(), + }); + } + }, + img { + src: asset!("assets/arrow-right-svgrepo-com.svg"), + alt: "Connect", + } + } + } + ) } } } - div { - label { - for: "username-entry", - "Username:" - //style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;", - } - input { - id: "username-entry", - placeholder: "username", - value: "{username.read()}", - autofocus: "true", - oninput: move |evt| username.set(evt.value().clone()), - } + match &*STATE.status.read() { + Failed(msg) => rsx!( + div { + class: "server-list", + div { + class: "login_error", + "Failed to connect:" + pre { "{msg}" } + } + } + ), + _ => rsx!(), + } + button { + class: "add-server-btn", + onclick: move |_| show_add_modal.set(true), + "+ Add Server" } - div { - match &*last_status.read() { - None => rsx!(div { - class: "login_status", - span {"···"} - }), - Some(Ok(ServerStatus { success: false, .. })) => rsx!(div { - class: "login_status is_error", - span { - "Could not reach server" - } - }), - Some(Ok(status)) => rsx!(div { - class: "login_status", - if let (Some(users), Some(max_users)) = (status.users, status.max_users) { - span {"{users}/{max_users} Online"} - } else { - span {"Unknown Online"} - } - span {"-"} - if let Some((maj, min, pat)) = status.version { - span {"Version: {maj}.{min}.{pat}"} - } else { - span {"Unknown Version"} - } - }), - Some(Err(_)) => rsx!(div { - class: "login_status is_error", - span { - "Could not reach proxy server" - } - }), - } - div { - {bottom} - } + if *show_add_modal.read() { + AddServerModal { + on_save: move |entry: ServerEntry| { + servers.write().push(entry); + Platform::save_servers(&servers.read()); + show_add_modal.set(false); + }, + on_cancel: move |_| show_add_modal.set(false), + } + } + + if let Some(idx) = *editing_index.read() { + if let Some(entry) = servers.read().get(idx).cloned() { + EditServerModal { + entry, + on_save: move |updated: ServerEntry| { + servers.write()[idx] = updated; + Platform::save_servers(&servers.read()); + editing_index.set(None); + }, + on_delete: move |_| { + servers.write().remove(idx); + Platform::save_servers(&servers.read()); + editing_index.set(None); + }, + on_cancel: move |_| editing_index.set(None), + } + } } } ) - // rsx!( - // div { - // class: "{login_box}", - // h1 { - // "Mumble Web" - // } - // input { - // placeholder: "username", - // value: "{username.read()}", - // autofocus: "true", - // oninput: move |evt| username.set(evt.value().clone()), - // } - // input { - // placeholder: "server address", - // value: "{address.read()}", - // autofocus: "true", - // oninput: move |evt| address_input.set(Some(evt.value().clone())), - // } - // {bottom} - // } - // ) +} + +/// Displays live ping info (user count, latency) for a server using the mumble UDP ping protocol. +#[component] +fn ServerPingInfo(address: String, port: u16) -> Element { + 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] +fn AddServerModal(on_save: EventHandler, on_cancel: EventHandler<()>) -> Element { + let mut name = use_signal(|| String::new()); + let mut address = use_signal(|| String::new()); + let mut port = use_signal(|| "64738".to_string()); + let mut username = use_signal(|| Platform::load_username().unwrap_or_default()); + let mut password = use_signal(|| String::new()); + + let do_save = move |_| { + let port_num: u16 = port.read().parse().unwrap_or(64738); + on_save.call(ServerEntry { + name: name.read().clone(), + address: address.read().clone(), + port: port_num, + username: username.read().clone(), + password: if password.read().is_empty() { + None + } else { + Some(password.read().clone()) + }, + }); + }; + + rsx! { + div { + class: "modal-backdrop", + onclick: move |_| on_cancel.call(()), + } + div { + class: "modal-container", + onclick: move |evt| evt.stop_propagation(), + div { + class: "modal", + h2 { "Add Server" } + div { + class: "modal-field", + label { "Name" } + input { + r#type: "text", + placeholder: "My Mumble Server", + value: "{name.read()}", + oninput: move |evt| name.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Address" } + input { + r#type: "text", + placeholder: "mumble.example.com", + value: "{address.read()}", + oninput: move |evt| address.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Port" } + input { + r#type: "number", + placeholder: "64738", + value: "{port.read()}", + oninput: move |evt| port.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Username" } + input { + r#type: "text", + placeholder: "Nickname", + value: "{username.read()}", + oninput: move |evt| username.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Password (optional)" } + input { + r#type: "password", + placeholder: "Password", + value: "{password.read()}", + oninput: move |evt| password.set(evt.value().clone()), + } + } + div { + class: "modal-actions", + button { + class: "modal-btn", + onclick: move |_| on_cancel.call(()), + "Cancel" + } + button { + class: "modal-btn modal-btn--primary", + disabled: address.read().is_empty() || username.read().is_empty(), + onclick: do_save, + "Save" + } + } + } + } + } +} + +#[component] +fn EditServerModal( + entry: ServerEntry, + on_save: EventHandler, + on_delete: EventHandler<()>, + on_cancel: EventHandler<()>, +) -> Element { + let mut name = use_signal(|| entry.name.clone()); + let mut address = use_signal(|| entry.address.clone()); + let mut port = use_signal(|| entry.port.to_string()); + let mut username = use_signal(|| entry.username.clone()); + let mut password = use_signal(|| entry.password.clone().unwrap_or_default()); + + let do_save = move |_| { + let port_num: u16 = port.read().parse().unwrap_or(64738); + on_save.call(ServerEntry { + name: name.read().clone(), + address: address.read().clone(), + port: port_num, + username: username.read().clone(), + password: if password.read().is_empty() { + None + } else { + Some(password.read().clone()) + }, + }); + }; + + rsx! { + div { + class: "modal-backdrop", + onclick: move |_| on_cancel.call(()), + } + div { + class: "modal-container", + onclick: move |evt| evt.stop_propagation(), + div { + class: "modal", + h2 { "Edit Server" } + div { + class: "modal-field", + label { "Name" } + input { + r#type: "text", + placeholder: "My Mumble Server", + value: "{name.read()}", + oninput: move |evt| name.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Address" } + input { + r#type: "text", + placeholder: "mumble.example.com", + value: "{address.read()}", + oninput: move |evt| address.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Port" } + input { + r#type: "number", + placeholder: "64738", + value: "{port.read()}", + oninput: move |evt| port.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Username" } + input { + r#type: "text", + placeholder: "Nickname", + value: "{username.read()}", + oninput: move |evt| username.set(evt.value().clone()), + } + } + div { + class: "modal-field", + label { "Password (optional)" } + input { + r#type: "password", + placeholder: "Password", + value: "{password.read()}", + oninput: move |evt| password.set(evt.value().clone()), + } + } + div { + class: "modal-actions", + button { + class: "modal-btn modal-btn--danger", + onclick: move |_| on_delete.call(()), + "Delete" + } + span { class: "modal-actions__spacer" } + button { + class: "modal-btn", + onclick: move |_| on_cancel.call(()), + "Cancel" + } + button { + class: "modal-btn modal-btn--primary", + disabled: address.read().is_empty() || username.read().is_empty(), + onclick: do_save, + "Save" + } + } + } + } + } } pub fn app() -> Element { diff --git a/gui/src/imp/desktop.rs b/gui/src/imp/desktop.rs index 34a59f8..4d746be 100644 --- a/gui/src/imp/desktop.rs +++ b/gui/src/imp/desktop.rs @@ -1,8 +1,8 @@ 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, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::collections::HashMap; use std::time::Duration; @@ -46,6 +46,22 @@ impl super::PlatformInterface for DesktopPlatform { 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); + } + } + async fn network_connect( address: String, username: String, @@ -59,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; @@ -81,8 +101,8 @@ impl super::PlatformInterface for DesktopPlatform { fn get_config_path() -> std::path::PathBuf { let strategy = choose_app_strategy(AppStrategyArgs { - top_level_domain: "com".to_string(), - author: "Ohea Corp".to_string(), + top_level_domain: "xyz".to_string(), + author: "ohea".to_string(), app_name: "Mumble Web2".to_string(), }) .expect("failed to choose app strategy"); @@ -106,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 cac7b86..6464bad 100644 --- a/gui/src/imp/mobile.rs +++ b/gui/src/imp/mobile.rs @@ -1,7 +1,7 @@ use crate::app::Command; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ClientConfig, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::future::Future; use std::time::Duration; @@ -31,10 +31,16 @@ impl super::PlatformInterface for MobilePlatform { None } - fn set_default_server(server: &str) -> Option<()> { + fn set_default_server(_server: &str) -> Option<()> { None } + fn load_servers() -> Vec { + Vec::new() + } + + fn save_servers(_servers: &[ServerEntry]) {} + async fn network_connect( address: String, username: String, @@ -48,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 a657d31..8a72548 100644 --- a/gui/src/imp/mod.rs +++ b/gui/src/imp/mod.rs @@ -7,7 +7,7 @@ use crate::{app::Command, effects::AudioProcessor}; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ClientConfig, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::future::Future; use std::time::Duration; @@ -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>; @@ -90,6 +96,12 @@ pub trait PlatformInterface { /// 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 d03cd5e..2a95304 100644 --- a/gui/src/imp/stub.rs +++ b/gui/src/imp/stub.rs @@ -3,7 +3,7 @@ use crate::effects::AudioProcessor; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ClientConfig, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::future::Future; pub struct StubPlatform; @@ -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") } } @@ -54,6 +61,14 @@ impl super::PlatformInterface for StubPlatform { 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 1a55ec4..8f83747 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::{ClientConfig, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use reqwest::Url; use std::future::Future; use std::sync::Arc; @@ -129,6 +129,24 @@ impl super::PlatformInterface for WebPlatform { 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 network_connect( address: String, username: String, @@ -147,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; }