From 8bbb3140ef0923de1ed100cffb2af292cb3ee0d1 Mon Sep 17 00:00:00 2001 From: restitux Date: Tue, 5 May 2026 04:10:50 +0000 Subject: [PATCH] Refresh login screen with server list UI and refactor platform config Redesign login view with server card list, add/edit/delete modals, and per-server ping status display. Rename ProxyOverrides to ClientConfig, remove ConfigSystemInterface in favor of direct platform methods (load_config, load_username, set_default_username, load_server_url), remove SharedState threading in favor of global STATE, simplify network_loop and audio setup, update proxy endpoint from /overrides to /config, and clean up desktop launch configuration. Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 1 - .gitea/workflows/build-release.yaml | 41 -- common/src/lib.rs | 2 +- docker/Caddyfile | 16 +- docker/docker-compose.yaml | 2 +- gui/assets/arrow-right-svgrepo-com.svg | 4 + gui/assets/delete-2-svgrepo-com.svg | 8 + gui/assets/earth-14-svgrepo-com.svg | 135 +++++ gui/assets/edit-3-svgrepo-com.svg | 5 + gui/assets/main.scss | 347 +++++++++++- gui/src/app.rs | 730 +++++++++++++++++-------- gui/src/imp/android.rs | 91 --- gui/src/imp/connect.rs | 13 +- gui/src/imp/desktop.rs | 48 +- gui/src/imp/mobile.rs | 45 +- gui/src/imp/mod.rs | 58 +- gui/src/imp/native_config.rs | 117 ---- gui/src/imp/stub.rs | 45 +- gui/src/imp/web.rs | 142 ++--- gui/src/lib.rs | 57 +- gui/src/main.rs | 18 +- proxy/src/main.rs | 25 +- 22 files changed, 1185 insertions(+), 765 deletions(-) delete mode 100644 .dockerignore create mode 100644 gui/assets/arrow-right-svgrepo-com.svg create mode 100644 gui/assets/delete-2-svgrepo-com.svg create mode 100644 gui/assets/earth-14-svgrepo-com.svg create mode 100644 gui/assets/edit-3-svgrepo-com.svg delete mode 100644 gui/src/imp/android.rs delete mode 100644 gui/src/imp/native_config.rs diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index eb5a316..0000000 --- a/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -target diff --git a/.gitea/workflows/build-release.yaml b/.gitea/workflows/build-release.yaml index 945d616..5015681 100644 --- a/.gitea/workflows/build-release.yaml +++ b/.gitea/workflows/build-release.yaml @@ -42,47 +42,6 @@ jobs: path: target/release/mumble-web2-proxy retention-days: 5 - macos_build: - runs-on: macos - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Restore Rust cache - uses: actions/cache/restore@v4 - with: - path: | - ~/.cargo - ./target - key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - rust-${{ runner.os }}- - - - name: Install cargo binstall - run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash - - - name: Install dioxus-cli - run: cargo binstall dioxus-cli --version 0.7.3 --no-confirm - - - name: Build dioxus project - run: dx bundle --platform macos --release -p mumble-web2-gui - - - name: Save Rust cache - if: always() - uses: actions/cache/save@v4 - with: - path: | - ~/.cargo - ./target - key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} - - - name: Upload mumble-web2-gui Artifact - uses: https://gitea.com/actions/gitea-upload-artifact@v4 - with: - name: mumble-web2-gui-macos-arm64 - path: gui/dist - retention-days: 5 - windows_build: runs-on: windows steps: diff --git a/common/src/lib.rs b/common/src/lib.rs index b1f2908..5845f5d 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Deserialize, Serialize, Default)] -pub struct ProxyOverrides { +pub struct ClientConfig { pub proxy_url: Option, pub cert_hash: Option>, pub any_server: bool, diff --git a/docker/Caddyfile b/docker/Caddyfile index 449ff36..247bd85 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -1,12 +1,14 @@ localhost:64444 { - tls internal + tls internal - # Proxy /config path to mumble-web2-proxy - reverse_proxy /overrides http://127.0.0.1:4400 + # Proxy /config path to mumble-web2-proxy + reverse_proxy /config http://127.0.0.1:4400 - # Proxy /status path to mumble-web2-proxy - reverse_proxy /status http://127.0.0.1:4400 + # Proxy /status path to mumble-web2-proxy + reverse_proxy /status http://127.0.0.1:4400 + + + # Proxy root path to dx-serve + reverse_proxy http://127.0.0.1:8080 - # Proxy root path to dx-serve - reverse_proxy http://127.0.0.1:8080 } diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index fc17325..8649f57 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -20,7 +20,7 @@ services: # volumes: # - ..:/app # environment: - # - MUMBLE_WEB2_PROXY_OVERRIDES_URL=https://localhost:64444/overrides + # - MUMBLE_WEB2_GUI_CONFIG_URL=https://localhost:64444/config # stdin_open: true # tty: true # command: > 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 4584e68..20a0040 100644 --- a/gui/assets/main.scss +++ b/gui/assets/main.scss @@ -16,7 +16,6 @@ body { } #main { - visibility: visible; height: 100vh; display: flex; flex-direction: column; @@ -432,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 8034f3e..60e65ff 100644 --- a/gui/src/app.rs +++ b/gui/src/app.rs @@ -2,17 +2,15 @@ use dioxus::prelude::*; use mime_guess::Mime; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry}; use ordermap::OrderSet; use std::collections::{HashMap, HashSet}; -use std::{fmt, sync::Arc}; -use crate::imp::{ConfigSystem, ConfigSystemInterface as _, Platform, PlatformInterface as _}; +use crate::imp::{Platform, PlatformInterface as _}; pub type ChannelId = u32; pub type UserId = u32; -#[derive(Debug)] pub enum ConnectionState { Disconnected, Connecting, @@ -20,17 +18,12 @@ pub enum ConnectionState { Failed(String), } -#[derive(Debug, Clone)] -pub struct AudioSettings { - pub denoise: bool, -} - #[derive(Debug)] pub enum Command { Connect { address: String, username: String, - config: ProxyOverrides, + config: ClientConfig, }, SendChat { markdown: String, @@ -52,14 +45,16 @@ pub enum Command { channel: ChannelId, user: UserId, }, - UpdateAudioSettings(AudioSettings), + UpdateMicEffects { + denoise: bool, + }, Disconnect, } use Command::*; use ConnectionState::*; -#[derive(Default, Debug)] +#[derive(Default)] pub struct UserState { pub name: String, pub channel: ChannelId, @@ -84,14 +79,13 @@ impl UserState { } } -#[derive(Debug)] pub struct Chat { pub raw: String, pub dangerous_html: String, pub sender: Option, } -#[derive(Default, Debug)] +#[derive(Default)] pub struct ChannelState { pub name: String, pub children: OrderSet, @@ -117,7 +111,7 @@ impl ChannelState { } } -#[derive(Default, Debug)] +#[derive(Default)] pub struct ChannelsState { pub channels: HashMap, } @@ -204,7 +198,7 @@ impl ChannelsState { } } -#[derive(Default, Debug)] +#[derive(Default)] pub struct ServerState { pub channels_state: ChannelsState, pub users: HashMap, @@ -219,21 +213,14 @@ impl ServerState { } pub struct State { - pub status: Signal, - pub server: Signal, - pub audio: Signal, + pub status: GlobalSignal, + pub server: GlobalSignal, } -impl fmt::Debug for State { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("State") - .field("status", &self.status.read()) - .field("server", &self.server.read()) - .finish() - } -} - -pub type SharedState = Arc; +pub static STATE: State = State { + status: Signal::global(|| Disconnected), + server: Signal::global(|| Default::default()), +}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum UserIcon { @@ -280,8 +267,7 @@ pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element { #[component] pub fn User(id: UserId) -> Element { - let state = use_context::(); - let server = state.server.read(); + let server = STATE.server.read(); match server.users.get(&id) { Some(state) => rsx!(UserPill { name: state.name.clone(), @@ -299,8 +285,7 @@ pub fn User(id: UserId) -> Element { #[component] pub fn Channel(id: ChannelId) -> Element { let net: Coroutine = use_coroutine_handle(); - let state = use_context::(); - let server = state.server.read(); + let server = STATE.server.read(); let user = server.session.unwrap(); let Some(state) = server.channels_state.channels.get(&id) else { return rsx!("missing channel {id}"); @@ -369,8 +354,7 @@ pub fn Channel(id: ChannelId) -> Element { #[cfg(any(feature = "desktop", feature = "web"))] pub fn pick_and_send_file(net: &Coroutine) { - let state = use_context::(); - let channels = if let Some(user) = state.server.read().this_user() { + let channels = if let Some(user) = STATE.server.read().this_user() { vec![user.channel] } else { return; @@ -396,14 +380,11 @@ pub fn pick_and_send_file(net: &Coroutine) {} #[component] pub fn ChatView() -> Element { let net: Coroutine = use_coroutine_handle(); - let state = use_context::(); - let server = state.server.read(); + let server = STATE.server.read(); let mut draft = use_signal(|| "".to_string()); let mut do_send = move || { - let state = use_context::(); - let server = state.server.read(); - if let Some(user) = server.this_user() { + if let Some(user) = STATE.server.read().this_user() { net.send(SendChat { markdown: draft.write().split_off(0), channels: vec![user.channel], @@ -473,12 +454,10 @@ pub fn ChatView() -> Element { } #[component] -pub fn ControlView(overrides: Resource) -> Element { +pub fn ControlView(config: Resource) -> Element { let net: Coroutine = use_coroutine_handle(); - let state = use_context::(); - let status = &state.status; - let server = state.server.read(); - let audio = state.audio.read(); + let status = &STATE.status; + let server = STATE.server.read(); let Some(&UserState { deaf, self_deaf, @@ -495,10 +474,10 @@ pub fn ControlView(overrides: Resource) -> Element { let current_channel_name = server.channels_state.channels[&channel].name.clone(); - let proxy_url = overrides + let proxy_url = config .read_unchecked() .as_ref() - .and_then(|overrides| overrides.proxy_url.clone()); + .and_then(|gui_config| gui_config.proxy_url.clone()); let connecting_color = "yellow"; let connected_color = "oklch(0.55 0.1184 141.35)"; @@ -576,6 +555,7 @@ pub fn ControlView(overrides: Resource) -> Element { }, }; + let denoise = use_signal(|| false); rsx!( // Server control div { @@ -616,23 +596,18 @@ pub fn ControlView(overrides: Resource) -> Element { } span { class: "spacer" } button { - class: match audio.denoise { + class: match denoise() { true => "toggle_button is_on", false => "toggle_button", }, role: "switch", - aria_checked: audio.denoise, + aria_checked: denoise(), onclick: move |_| { - let state = use_context::(); - let mut audio = state.audio.read().clone(); - audio.denoise = !audio.denoise; - let denoise = audio.denoise; - *state.audio.write_unchecked() = audio; - net.send(UpdateAudioSettings(AudioSettings { denoise: denoise })); - let user_config = use_context::(); - user_config.config_set::("denoise", &denoise); + let new_denoise = !denoise(); + *denoise.write_unchecked() = new_denoise; + net.send(UpdateMicEffects { denoise: new_denoise }) }, - match audio.denoise { + match denoise() { true => rsx!(span { class: "material-symbols-outlined", "cadence"}), false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}), } @@ -670,10 +645,9 @@ pub fn ControlView(overrides: Resource) -> Element { } #[component] -pub fn ServerView(overrides: Resource) -> Element { +pub fn ServerView(config: Resource) -> Element { let net: Coroutine = use_coroutine_handle(); - let state = use_context::(); - let server = state.server.read(); + let server = STATE.server.read(); let Some(&UserState { deaf, self_deaf, @@ -702,227 +676,507 @@ pub fn ServerView(overrides: Resource) -> Element { } div { class: "server_control_box", - ControlView { overrides } + ControlView { config } } } ) } #[component] -pub fn LoginView(overrides: Resource) -> Element { - let user_config = use_context::(); +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(|| user_config.config_get::("server_url")); - let address = use_memo(move || { - if let Some(addr) = address_input() { - addr.clone() - } else { - overrides() - .and_then(|c| c.proxy_url.clone()) - .unwrap_or_default() - } - }); + let version = option_env!("MUMBLE_WEB2_VERSION"); - let mut username = use_signal(|| { - user_config - .config_get::("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 _ = user_config.config_set::("username", &username.read()); - if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) { - user_config.config_set::("server_url", &address.read()); - } - net.send(Connect { - address: address.read().clone(), - username: username.read().clone(), - config: overrides.read().clone().unwrap_or_default(), - }) - }; - let state = use_context::(); - 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 overrides.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 { static STYLE: Asset = asset!("/assets/main.scss"); - use_effect(|| { - Platform::request_permissions(); - }); - - let user_config = use_root_context(|| ConfigSystem::new().unwrap()); - let state = use_root_context(|| { - SharedState::new(State { - status: Signal::new(Disconnected), - server: Signal::new(Default::default()), - audio: Signal::new(AudioSettings { - denoise: user_config.config_get::("denoise").unwrap_or(true), - }), - }) - }); - - let network_state = state.clone(); - use_coroutine(move |rx: UnboundedReceiver| { - super::network_entrypoint(rx, network_state.clone()) - }); - let overrides = use_resource(|| async move { - match Platform::load_proxy_overrides().await { - Ok(overrides) => overrides, - Err(_) => ProxyOverrides::default(), + use_coroutine(|rx: UnboundedReceiver| super::network_entrypoint(rx)); + let config = use_resource(|| async move { + match Platform::load_config().await { + Ok(config) => config, + Err(_) => ClientConfig::default(), } }); + Platform::request_permissions(); + rsx!( document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" } document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" } document::Link{ rel: "stylesheet", href: STYLE } - match *state.status.read() { - Connected => rsx!(ServerView { overrides }), - _ => rsx!(LoginView { overrides }), + match *STATE.status.read() { + Connected => rsx!(ServerView { config }), + _ => rsx!(LoginView { config }), } ) } diff --git a/gui/src/imp/android.rs b/gui/src/imp/android.rs deleted file mode 100644 index cac7b86..0000000 --- a/gui/src/imp/android.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::app::Command; -use color_eyre::eyre::Error; -use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ClientConfig, ServerStatus}; -use std::future::Future; -use std::time::Duration; - -/// Mobile platform implementation using Tokio, native audio, and Android permissions. -pub struct MobilePlatform; - -impl super::PlatformInterface for MobilePlatform { - type AudioSystem = super::native_audio::NativeAudioSystem; - - async fn load_config() -> color_eyre::Result { - Ok(ClientConfig { - proxy_url: None, - cert_hash: None, - any_server: true, - }) - } - - fn load_username() -> Option { - None - } - - fn load_server_url() -> Option { - None - } - - fn set_default_username(_username: &str) -> Option<()> { - None - } - - fn set_default_server(server: &str) -> Option<()> { - None - } - - async fn network_connect( - address: String, - username: String, - event_rx: &mut UnboundedReceiver, - gui_config: &ClientConfig, - ) -> Result<(), Error> { - super::connect::network_connect(address, username, event_rx, gui_config).await - } - - async fn get_status(client: &reqwest::Client) -> color_eyre::Result { - super::connect::get_status(client).await - } - - fn init_logging() { - use tracing::level_filters::LevelFilter; - use tracing_subscriber::filter::EnvFilter; - - let env_filter = EnvFilter::builder() - .with_default_directive(LevelFilter::INFO.into()) - .from_env_lossy(); - - tracing_subscriber::fmt() - .with_target(true) - .with_level(true) - .with_env_filter(env_filter) - .init(); - } - - fn request_permissions() { - request_recording_permission(); - } - - async fn sleep(duration: Duration) { - tokio::time::sleep(duration).await; - } -} - -#[cfg(not(target_os = "android"))] -pub fn request_recording_permission() {} - -#[cfg(target_os = "android")] -pub fn request_recording_permission() { - use android_permissions::{PermissionManager, RECORD_AUDIO}; - use jni::{objects::JObject, JavaVM}; - - let ctx = ndk_context::android_context(); - let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() }; - let activity = unsafe { JObject::from_raw(ctx.context().cast()) }; - - let manager = PermissionManager::create(vm, activity).unwrap(); - if !manager.check(&RECORD_AUDIO).unwrap() { - manager.request(&[&RECORD_AUDIO]).unwrap(); - } -} diff --git a/gui/src/imp/connect.rs b/gui/src/imp/connect.rs index e4e7b96..e7d3ce7 100644 --- a/gui/src/imp/connect.rs +++ b/gui/src/imp/connect.rs @@ -1,4 +1,4 @@ -use crate::app::{Command, SharedState}; +use crate::app::Command; use color_eyre::eyre::{bail, Error}; use dioxus::hooks::UnboundedReceiver; use mumble_protocol::control::ClientControlCodec; @@ -8,13 +8,13 @@ use tokio::net::TcpStream; use tokio_rustls::rustls; use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier}; use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime}; -use tokio_rustls::rustls::ClientConfig; +use tokio_rustls::rustls::ClientConfig as RlsClientConfig; use tokio_rustls::rustls::DigitallySignedStruct; use tokio_rustls::TlsConnector; use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; use tracing::{info, instrument}; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerStatus}; #[derive(Debug)] struct NoCertificateVerification; @@ -73,12 +73,11 @@ pub async fn network_connect( address: String, username: String, event_rx: &mut UnboundedReceiver, - overrides: &ProxyOverrides, - state: SharedState, + gui_config: &ClientConfig, ) -> Result<(), Error> { info!("connecting"); - let config = ClientConfig::builder() + let config = RlsClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) .with_no_client_auth(); @@ -103,7 +102,7 @@ pub async fn network_connect( let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec); let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec); - crate::network_loop(username, state, event_rx, reader, writer).await + crate::network_loop(username, event_rx, reader, writer).await } pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result { diff --git a/gui/src/imp/desktop.rs b/gui/src/imp/desktop.rs index 858eb1b..4d746be 100644 --- a/gui/src/imp/desktop.rs +++ b/gui/src/imp/desktop.rs @@ -1,8 +1,8 @@ -use crate::app::{Command, SharedState}; +use crate::app::Command; use color_eyre::eyre::{bail, Error}; use dioxus::hooks::UnboundedReceiver; use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; -use mumble_web2_common::{ProxyOverrides, ServerEntry, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::collections::HashMap; use std::time::Duration; @@ -11,36 +11,33 @@ pub struct DesktopPlatform; impl super::PlatformInterface for DesktopPlatform { type AudioSystem = super::native_audio::NativeAudioSystem; - type ConfigSystem = super::native_config::NativeConfigSystem; async fn sleep(duration: Duration) { tokio::time::sleep(duration).await; } - async fn load_proxy_overrides() -> color_eyre::Result { - Ok(ProxyOverrides { + async fn load_config() -> color_eyre::Result { + Ok(ClientConfig { proxy_url: None, cert_hash: None, any_server: true, }) } - async fn network_connect( - address: String, - username: String, - event_rx: &mut UnboundedReceiver, - overrides: &ProxyOverrides, - state: SharedState, - ) -> Result<(), Error> { - super::connect::network_connect(address, username, event_rx, overrides, state).await + fn load_username() -> Option { + let config = load_config_map(); + config.get("username").cloned() } - async fn get_status(client: &reqwest::Client) -> color_eyre::Result { - super::connect::get_status(client).await + fn load_server_url() -> Option { + let config = load_config_map(); + config.get("server").cloned() } - async fn ping_server(address: &str, port: u16) -> color_eyre::Result { - mumble_udp_ping(address, port).await + fn set_default_username(username: &str) -> Option<()> { + let mut config = load_config_map(); + config.insert("username".to_string(), username.to_string()); + save_config_map(&config).ok() } fn set_default_server(server: &str) -> Option<()> { @@ -65,6 +62,23 @@ impl super::PlatformInterface for DesktopPlatform { } } + async fn network_connect( + address: String, + username: String, + event_rx: &mut UnboundedReceiver, + gui_config: &ClientConfig, + ) -> Result<(), Error> { + super::connect::network_connect(address, username, event_rx, gui_config).await + } + + async fn get_status(client: &reqwest::Client) -> color_eyre::Result { + 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; diff --git a/gui/src/imp/mobile.rs b/gui/src/imp/mobile.rs index d0ec222..6464bad 100644 --- a/gui/src/imp/mobile.rs +++ b/gui/src/imp/mobile.rs @@ -1,7 +1,8 @@ -use crate::app::{Command, SharedState}; +use crate::app::Command; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ProxyOverrides, ServerEntry, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; +use std::future::Future; use std::time::Duration; /// Mobile platform implementation using Tokio, native audio, and Android permissions. @@ -9,32 +10,25 @@ pub struct MobilePlatform; impl super::PlatformInterface for MobilePlatform { type AudioSystem = super::native_audio::NativeAudioSystem; - type ConfigSystem = super::native_config::NativeConfigSystem; - async fn load_proxy_overrides() -> color_eyre::Result { - Ok(ProxyOverrides { + async fn load_config() -> color_eyre::Result { + Ok(ClientConfig { proxy_url: None, cert_hash: None, any_server: true, }) } - async fn network_connect( - address: String, - username: String, - event_rx: &mut UnboundedReceiver, - overrides: &ProxyOverrides, - state: SharedState, - ) -> Result<(), Error> { - super::connect::network_connect(address, username, event_rx, overrides, state).await + fn load_username() -> Option { + None } - async fn get_status(client: &reqwest::Client) -> color_eyre::Result { - super::connect::get_status(client).await + fn load_server_url() -> Option { + None } - async fn ping_server(_address: &str, _port: u16) -> color_eyre::Result { - color_eyre::eyre::bail!("ping not supported on mobile yet") + fn set_default_username(_username: &str) -> Option<()> { + None } fn set_default_server(_server: &str) -> Option<()> { @@ -47,6 +41,23 @@ impl super::PlatformInterface for MobilePlatform { fn save_servers(_servers: &[ServerEntry]) {} + async fn network_connect( + address: String, + username: String, + event_rx: &mut UnboundedReceiver, + gui_config: &ClientConfig, + ) -> Result<(), Error> { + super::connect::network_connect(address, username, event_rx, gui_config).await + } + + async fn get_status(client: &reqwest::Client) -> color_eyre::Result { + 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 6099232..8a72548 100644 --- a/gui/src/imp/mod.rs +++ b/gui/src/imp/mod.rs @@ -4,12 +4,10 @@ //! The traits make the platform boundary explicit and provide compile-time verification. #![allow(async_fn_in_trait)] -use crate::app::{Command, SharedState}; -use crate::effects::AudioProcessor; +use crate::{app::Command, effects::AudioProcessor}; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ProxyOverrides, ServerEntry, ServerStatus}; -use std::collections::HashMap; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::future::Future; use std::time::Duration; @@ -52,24 +50,11 @@ pub trait AudioPlayerInterface { fn play_opus(&mut self, payload: &[u8]); } -pub trait ConfigSystemInterface: Sized + Clone { - fn new() -> Result; - - fn config_get(&self, key: &str) -> Option - where - T: serde::de::DeserializeOwned; - - fn config_set(&self, key: &str, value: &T) - where - T: serde::Serialize; -} - /// This is the main trait that each platform must implement. It combines all /// platform-specific functionality into a single interface, providing compile-time /// verification that all platforms implement the required functionality. pub trait PlatformInterface { type AudioSystem: AudioSystemInterface; - type ConfigSystem: ConfigSystemInterface; /// Initialize logging for the platform. fn init_logging(); @@ -82,11 +67,10 @@ pub trait PlatformInterface { address: String, username: String, event_rx: &mut UnboundedReceiver, - proxy_overrides: &ProxyOverrides, - state: SharedState, + 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>; @@ -98,7 +82,16 @@ pub trait PlatformInterface { ) -> impl Future>; /// Load the proxy overrides (proxy URL, cert hash, etc.). - fn load_proxy_overrides() -> impl Future>; + fn load_config() -> impl Future>; + + /// Load saved username. + fn load_username() -> Option; + + /// Load saved server URL. + fn load_server_url() -> Option; + + /// Save the default username. + fn set_default_username(username: &str) -> Option<()>; /// Save the default server URL. fn set_default_server(server: &str) -> Option<()>; @@ -117,21 +110,15 @@ pub trait PlatformInterface { // Platform Modules // ============================================================================ -mod stub; - #[cfg(any(feature = "desktop", feature = "mobile"))] mod connect; -#[cfg(any(feature = "desktop", feature = "mobile"))] -mod native_audio; -#[cfg(any(feature = "desktop", feature = "mobile"))] -mod native_config; - #[cfg(feature = "desktop")] mod desktop; - #[cfg(feature = "mobile")] mod mobile; - +#[cfg(any(feature = "desktop", feature = "mobile"))] +mod native_audio; +mod stub; #[cfg(feature = "web")] mod web; @@ -158,8 +145,6 @@ pub type Platform = stub::StubPlatform; pub type AudioSystem = ::AudioSystem; pub type AudioPlayer = ::AudioPlayer; -pub type ConfigSystem = ::ConfigSystem; - // ======================== // Platform Async Runtime // ======================== @@ -191,12 +176,3 @@ const _: () = { let _ = assert_platform::; let _ = assert_platform::; }; - -fn global_default_config() -> HashMap { - serde_json::json!({}) - .as_object() - .unwrap() - .clone() - .into_iter() - .collect() -} diff --git a/gui/src/imp/native_config.rs b/gui/src/imp/native_config.rs deleted file mode 100644 index 6ae3b20..0000000 --- a/gui/src/imp/native_config.rs +++ /dev/null @@ -1,117 +0,0 @@ -use color_eyre::eyre::Error; -use std::collections::HashMap; -use tracing::info; - -#[derive(Clone, PartialEq)] -pub struct NativeConfigSystem { - config_path: std::path::PathBuf, -} - -impl super::ConfigSystemInterface for NativeConfigSystem { - fn new() -> color_eyre::Result { - return Ok(NativeConfigSystem { - config_path: get_config_path()?, - }); - } - - fn config_get(&self, key: &str) -> Option - where - T: serde::de::DeserializeOwned, - { - let config = load_config_map(&self.config_path); - - let Some(value_untyped) = config.get(key).cloned().or_else(|| config_get_default(key)) - else { - return None; - }; - - match serde_json::from_value::(value_untyped) { - Ok(v) => Some(v), - Err(_) => { - let default_value = config_get_default(key) - .expect("Default value required after config parse failure"); - Some( - serde_json::from_value::(default_value) - .expect("Default value could not be parsed"), - ) - } - } - } - - fn config_set(&self, key: &str, value: &T) - where - T: serde::Serialize, - { - let mut config = load_config_map(&self.config_path); - let json_value = serde_json::to_value(value).expect("failed to serialize config value"); - config.insert(key.to_string(), json_value); - save_config_map(&config).expect("failed to set config") - } -} - -#[cfg(any(feature = "desktop"))] -fn get_config_path() -> color_eyre::Result { - use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; - - 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"); - Ok(strategy.config_dir().join("config.json")) -} - -#[cfg(target_os = "android")] -fn get_config_path() -> color_eyre::Result { - let ctx = ndk_context::android_context(); - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }?; - let mut env = vm.attach_current_thread()?; - let ctx = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; - let cache_dir = env - .call_method(ctx, "getFilesDir", "()Ljava/io/File;", &[])? - .l()?; - let cache_dir: jni::objects::JString = env - .call_method(&cache_dir, "toString", "()Ljava/lang/String;", &[])? - .l()? - .try_into()?; - let cache_dir = env.get_string(&cache_dir)?; - let cache_dir = cache_dir.to_str()?; - Ok(std::path::PathBuf::from(cache_dir).join("config.json")) -} - -fn load_config_map(config_path: &std::path::PathBuf) -> HashMap { - 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().expect("Could not get config file path."); - if let Some(parent) = config_path.parent() { - info!("Creating config directory: {}", parent.display()); - std::fs::create_dir_all(parent)?; - } - let contents = serde_json::to_string_pretty(config)?; - info!("Writing config to {}", config_path.display()); - std::fs::write(&config_path, contents)?; - Ok(()) -} - -fn config_get_default(key: &str) -> Option { - let default_config = platform_default_config(); - default_config - .get(key) - .cloned() - .or(super::global_default_config().get(key).cloned()) -} - -fn platform_default_config() -> HashMap { - serde_json::json!({}) - .as_object() - .unwrap() - .clone() - .into_iter() - .collect() -} diff --git a/gui/src/imp/stub.rs b/gui/src/imp/stub.rs index 84b625f..2a95304 100644 --- a/gui/src/imp/stub.rs +++ b/gui/src/imp/stub.rs @@ -1,16 +1,15 @@ /// Stub implementation of the platform interface, so that we can /// `cargo check` without any --feature flags. -use crate::{app::SharedState, effects::AudioProcessor}; +use crate::effects::AudioProcessor; use color_eyre::eyre::Error; use dioxus::hooks::UnboundedReceiver; -use mumble_web2_common::{ProxyOverrides, ServerEntry, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use std::future::Future; pub struct StubPlatform; impl super::PlatformInterface for StubPlatform { type AudioSystem = StubAudioSystem; - type ConfigSystem = StubConfigSystem; fn init_logging() { panic!("stubbed platform") @@ -24,8 +23,7 @@ impl super::PlatformInterface for StubPlatform { _address: String, _username: String, _event_rx: &mut UnboundedReceiver, - _overrides: &ProxyOverrides, - _state: SharedState, + _gui_config: &ClientConfig, ) -> impl Future> { async { panic!("stubbed platform") } } @@ -43,10 +41,22 @@ impl super::PlatformInterface for StubPlatform { async { panic!("stubbed platform") } } - fn load_proxy_overrides() -> impl Future> { + fn load_config() -> impl Future> { async { panic!("stubbed platform") } } + fn load_username() -> Option { + panic!("stubbed platform") + } + + fn load_server_url() -> Option { + panic!("stubbed platform") + } + + fn set_default_username(_username: &str) -> Option<()> { + panic!("stubbed platform") + } + fn set_default_server(_server: &str) -> Option<()> { panic!("stubbed platform") } @@ -97,29 +107,6 @@ impl super::AudioPlayerInterface for StubAudioPlayer { } } -#[derive(Clone)] -pub struct StubConfigSystem; - -impl super::ConfigSystemInterface for StubConfigSystem { - fn new() -> Result { - panic!("stubbed platform") - } - - fn config_get(&self, key: &str) -> Option - where - T: serde::de::DeserializeOwned, - { - panic!("stubbed platform") - } - - fn config_set(&self, key: &str, value: &T) - where - T: serde::Serialize, - { - panic!("stubbed platform") - } -} - #[allow(unused)] pub struct SpawnHandle; diff --git a/gui/src/imp/web.rs b/gui/src/imp/web.rs index 303c664..8f83747 100644 --- a/gui/src/imp/web.rs +++ b/gui/src/imp/web.rs @@ -1,4 +1,4 @@ -use crate::app::{Command, SharedState}; +use crate::app::Command; use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState}; use color_eyre::eyre::{bail, eyre, Error}; use crossbeam::atomic::AtomicCell; @@ -6,9 +6,8 @@ use dioxus::prelude::*; use gloo_timers::future::TimeoutFuture; use js_sys::Float32Array; use mumble_protocol::control::ClientControlCodec; -use mumble_web2_common::{ProxyOverrides, ServerEntry, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus}; use reqwest::Url; -use std::collections::HashMap; use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -63,7 +62,6 @@ pub struct WebPlatform; impl super::PlatformInterface for WebPlatform { type AudioSystem = WebAudioSystem; - type ConfigSystem = WebConfigSystem; fn init_logging() { // copied from tracing_web example usage @@ -91,43 +89,40 @@ impl super::PlatformInterface for WebPlatform { // No-op on web } - async fn load_proxy_overrides() -> color_eyre::Result { - let overrides = match option_env!("MUMBLE_WEB2_PROXY_OVERRIDES_URL") { + async fn load_config() -> color_eyre::Result { + let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") { Some(url) => Url::parse(url)?, - None => absolute_url("overrides")?, + None => absolute_url("config")?, }; - info!("loading config from {}", overrides); + info!("loading config from {}", config_url); - let config = reqwest::get(overrides) + let config = reqwest::get(config_url) .await? - .json::() + .json::() .await?; Ok(config) } - async fn network_connect( - address: String, - username: String, - event_rx: &mut UnboundedReceiver, - overrides: &ProxyOverrides, - state: SharedState, - ) -> Result<(), Error> { - network_connect(address, username, event_rx, overrides, state).await + fn load_username() -> Option { + web_sys::window() + .unwrap() + .local_storage() + .ok()?? + .get_item("username") + .ok()? } - async fn get_status(client: &reqwest::Client) -> color_eyre::Result { - Ok(client - .get(absolute_url("status")?) - .send() - .await? - .json::() - .await?) + fn load_server_url() -> Option { + None } - 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_username(username: &str) -> Option<()> { + web_sys::window()? + .local_storage() + .ok()?? + .set_item("username", username) + .ok() } fn set_default_server(_server: &str) -> Option<()> { @@ -152,6 +147,29 @@ impl super::PlatformInterface for WebPlatform { } } + async fn network_connect( + address: String, + username: String, + event_rx: &mut UnboundedReceiver, + gui_config: &ClientConfig, + ) -> Result<(), Error> { + network_connect(address, username, event_rx, gui_config).await + } + + async fn get_status(client: &reqwest::Client) -> color_eyre::Result { + Ok(client + .get(absolute_url("status")?) + .send() + .await? + .json::() + .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; } @@ -461,8 +479,7 @@ pub async fn network_connect( address: String, username: String, event_rx: &mut UnboundedReceiver, - overrides: &ProxyOverrides, - state: SharedState, + gui_config: &ClientConfig, ) -> Result<(), Error> { info!("connecting"); @@ -475,7 +492,7 @@ pub async fn network_connect( ) .ey()?; - if let Some(server_hash) = &overrides.cert_hash { + if let Some(server_hash) = &gui_config.cert_hash { let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice()); web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?; } @@ -521,7 +538,7 @@ pub async fn network_connect( let writer = asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec); - crate::network_loop(username, state, event_rx, reader, writer).await + crate::network_loop(username, event_rx, reader, writer).await } pub fn absolute_url(path: &str) -> Result { @@ -529,64 +546,3 @@ pub fn absolute_url(path: &str) -> Result { let location = window.location(); Ok(Url::parse(&location.href().ey()?)?.join(path)?) } - -#[derive(Clone, PartialEq)] -pub struct WebConfigSystem {} - -impl super::ConfigSystemInterface for WebConfigSystem { - fn new() -> Result { - return Ok(WebConfigSystem {}); - } - - fn config_get(&self, key: &str) -> Option - where - T: serde::de::DeserializeOwned, - { - // Get Storage - let storage = web_sys::window()?.local_storage().ok()??; - - // Try localStorage first - if let Ok(Some(raw)) = storage.get_item(key) { - if let Ok(parsed) = serde_json::from_str::(&raw) { - return Some(parsed); - } - } - - // Fallback to default if deserialization fails or key missing - let default_value = config_get_default(key)?; - serde_json::from_value::(default_value).ok() - } - - fn config_set(&self, key: &str, value: &T) - where - T: serde::Serialize, - { - let storage = window() - .and_then(|w| w.local_storage().ok().flatten()) - .expect("localStorage not available"); - - let json_value = - serde_json::to_string(value).expect("failed to serialize config value to JSON string"); - - storage - .set_item(key, &json_value) - .expect("failed to write to localStorage"); - } -} - -fn config_get_default(key: &str) -> Option { - let default_config = platform_default_config(); - default_config - .get(key) - .cloned() - .or(super::global_default_config().get(key).cloned()) -} - -fn platform_default_config() -> HashMap { - serde_json::json!({}) - .as_object() - .unwrap() - .clone() - .into_iter() - .collect() -} diff --git a/gui/src/lib.rs b/gui/src/lib.rs index 024d46f..bb875b2 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -1,6 +1,7 @@ use app::Chat; use app::Command; use app::ConnectionState; +use app::STATE; use asynchronous_codec::FramedRead; use asynchronous_codec::FramedWrite; use color_eyre::eyre::{bail, Error}; @@ -26,9 +27,6 @@ use std::time::Duration; use tracing::error; use tracing::info; -use crate::app::AudioSettings; -use crate::app::SharedState; -use crate::app::State; use crate::effects::AudioProcessor; use crate::imp::{ AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform, @@ -40,7 +38,7 @@ mod effects; pub mod imp; mod msghtml; -pub async fn network_entrypoint(mut event_rx: UnboundedReceiver, state: SharedState) { +pub async fn network_entrypoint(mut event_rx: UnboundedReceiver) { loop { let Some(Command::Connect { address, @@ -51,29 +49,25 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver, state: panic!("did not receive connect command") }; - *state.server.write_unchecked() = Default::default(); - *state.status.write_unchecked() = ConnectionState::Connecting; + *STATE.server.write() = Default::default(); + *STATE.status.write() = ConnectionState::Connecting; if let Err(error) = - Platform::network_connect(address, username, &mut event_rx, &config, state.clone()) - .await + Platform::network_connect(address, username, &mut event_rx, &config).await { error!("could not connect {:?}", error); - *state.status.write_unchecked() = ConnectionState::Failed(error.to_string()); + *STATE.status.write() = ConnectionState::Failed(error.to_string()); } else { - *state.status.write_unchecked() = ConnectionState::Disconnected; + *STATE.status.write() = ConnectionState::Disconnected; } } } pub async fn network_loop( username: String, - state: SharedState, event_rx: &mut UnboundedReceiver, mut reader: FramedRead>, mut writer: FramedWrite>, ) -> Result<(), Error> { - let audio_settings = state.audio.read().clone(); - let (mut send_chan, mut writer_recv_chan) = futures_channel::mpsc::unbounded(); spawn(async move { while let Some(msg) = writer_recv_chan.next().await { @@ -123,13 +117,10 @@ pub async fn network_loop AudioDecoder @@ -160,7 +149,7 @@ pub async fn network_loop break, Some(command) => { - let res = accept_command(command, &mut send_chan, &mut audio, &state); + let res = accept_command(command, &mut send_chan, &mut audio); if let Err(err) = res { info!("error accepting command {:?}", err) } @@ -198,10 +187,9 @@ fn accept_command( command: Command, send_chan: &mut UnboundedSender>, audio: &mut AudioSystem, - state: &State, ) -> Result<(), Error> { use Command::*; - let Some(session) = state.server.read().session else { + let Some(session) = STATE.server.read().session else { bail!("no session id") }; @@ -224,7 +212,7 @@ fn accept_command( }; { - let mut server = state.server.write_unchecked(); + let mut server = STATE.server.write(); let Some(me) = server.session else { bail!("not signed in with a session id") }; @@ -265,7 +253,7 @@ fn accept_command( }; { - let mut server = state.server.write_unchecked(); + let mut server = STATE.server.write(); let Some(me) = server.session else { bail!("not signed in with a session id") }; @@ -300,7 +288,7 @@ fn accept_command( let _ = send_chan.unbounded_send(u.into()); } Connect { .. } | Disconnect => (), - UpdateAudioSettings(AudioSettings { denoise }) => { + UpdateMicEffects { denoise } => { if denoise { audio.set_processor(AudioProcessor::new_denoising()); } else { @@ -316,7 +304,6 @@ fn accept_packet( msg: ControlPacket, audio_context: &mut AudioSystem, player_map: &mut HashMap, - state: &State, ) -> Result<(), Error> { match msg { ControlPacket::UDPTunnel(u) => { @@ -353,15 +340,15 @@ fn accept_packet( } } ControlPacket::ChannelState(u) => { - let mut server = state.server.write_unchecked(); + let mut server = STATE.server.write(); server.channels_state.update_from_channel_state(&u); } ControlPacket::ChannelRemove(u) => { - let mut server = state.server.write_unchecked(); + let mut server = STATE.server.write(); server.channels_state.update_from_channel_remove(&u); } ControlPacket::UserState(u) => { - let mut server = state.server.write_unchecked(); + let mut server = STATE.server.write(); let server = &mut *server; let id = u.get_session(); @@ -405,7 +392,7 @@ fn accept_packet( } } ControlPacket::UserRemove(u) => { - let mut server = state.server.write_unchecked(); + let mut server = STATE.server.write(); let id = u.get_session(); if let Some(state) = server.users.remove(&id) { if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) { @@ -414,7 +401,7 @@ fn accept_packet( } } ControlPacket::TextMessage(u) => { - let mut server = state.server.write_unchecked(); + let mut server = STATE.server.write(); if u.has_message() { let text = u.get_message().to_string(); server.chat.push(Chat { @@ -429,8 +416,8 @@ fn accept_packet( } } ControlPacket::ServerSync(u) => { - *state.status.write_unchecked() = ConnectionState::Connected; - let mut server = state.server.write_unchecked(); + *STATE.status.write() = ConnectionState::Connected; + let mut server = STATE.server.write(); if u.has_welcome_text() { let text = u.get_welcome_text().to_string(); server.chat.push(Chat { diff --git a/gui/src/main.rs b/gui/src/main.rs index 58ce7cf..a4185c6 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,22 +1,6 @@ -use dioxus::prelude::*; use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _}; pub fn main() { Platform::init_logging(); - dioxus::LaunchBuilder::new() - .with_cfg(desktop! { - dioxus::desktop::Config::new() - // Reduce white flash on startup by setting background color and hiding main element - .with_background_color((0, 0, 0, 255)) - .with_custom_head("".into()) - .with_disable_context_menu(cfg!(not(debug_assertions))) - .with_window( - dioxus::desktop::WindowBuilder::new() - .with_title("Mumble Web 2") - .with_min_inner_size(dioxus::desktop::LogicalSize::new(600.0, 300.0)) - .with_inner_size(dioxus::desktop::LogicalSize::new(900.0, 700.0)) - .with_maximized(false), - ) - }) - .launch(app::app); + dioxus::launch(app::app); } diff --git a/proxy/src/main.rs b/proxy/src/main.rs index 1f10a43..53f69db 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -1,5 +1,5 @@ use color_eyre::eyre::{anyhow, bail, Context, Result}; -use mumble_web2_common::{ProxyOverrides, ServerStatus}; +use mumble_web2_common::{ClientConfig, ServerStatus}; use rand::Rng; use salvo::conn::rustls::{Keycert, RustlsConfig}; use salvo::cors::{AllowOrigin, Cors}; @@ -16,7 +16,7 @@ use tokio::net::TcpStream; use tokio::pin; use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier}; use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime}; -use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct}; +use tokio_rustls::rustls::{ClientConfig as RlsClientConfig, DigitallySignedStruct}; use tokio_rustls::{rustls, TlsConnector}; use tracing::info; use tracing::info_span; @@ -77,7 +77,7 @@ async fn main() -> Result<()> { .install_default() .map_err(|e| anyhow!("could not install crypto provider {e:?}"))?; - let mut overrides = ProxyOverrides { + let mut client_config = ClientConfig { proxy_url: match &server_config.proxy_url { Some(url) => Some(url.to_string()), None => None, @@ -102,7 +102,7 @@ async fn main() -> Result<()> { let cert = cert_params.self_signed(&key_pair)?; let hash = hmac_sha256::Hash::hash(cert.der().as_ref()); - overrides.cert_hash = Some(hash.into()); + client_config.cert_hash = Some(hash.into()); (cert.pem().into(), key_pair.serialize_pem().into()) } @@ -122,11 +122,14 @@ async fn main() -> Result<()> { }; let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice())); - info!("proxy overrides:\n{}", toml::to_string_pretty(&overrides)?); + info!( + "client config:\n{}", + toml::to_string_pretty(&client_config)? + ); let config_craft = ConfigCraft { server_config: server_config.clone(), - overrides, + client_config, }; let status_craft = StatusCraft { @@ -136,7 +139,7 @@ async fn main() -> Result<()> { // Server routing let mut router = Router::new() .push(Router::with_path("/proxy").goal(config_craft.connect_proxy())) - .push(Router::with_path("/overrides").get(config_craft.get_overrides())) + .push(Router::with_path("/config").get(config_craft.get_config())) .push(Router::with_path("/status").get(status_craft.get_status())) .hoop(Logger::new()); if let Some(gui_path) = server_config.gui_path.clone() { @@ -249,14 +252,14 @@ impl StatusCraft { #[derive(Clone)] pub struct ConfigCraft { server_config: Arc, - overrides: ProxyOverrides, + client_config: ClientConfig, } #[craft] impl ConfigCraft { #[craft(handler)] - async fn get_overrides(&self) -> Json { - Json(self.overrides.clone()) + async fn get_config(&self) -> Json { + Json(self.client_config.clone()) } #[craft(handler)] @@ -317,7 +320,7 @@ async fn connect_proxy_impl( ) -> Result<()> { info!("connecting to Mumble server..."); - let config = ClientConfig::builder() + let config = RlsClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) .with_no_client_auth();