8 Commits

Author SHA1 Message Date
liamwarfield 5f3466546e Add some Trait doc comments
Build Mumble Web 2 / windows_build (push) Successful in 2m25s
Build Mumble Web 2 / linux_build (push) Successful in 1m18s
Build Mumble Web 2 / android_build (push) Successful in 5m47s
Added comments to Audio(System|Player)Interface traits.
2026-02-17 21:28:05 -07:00
liamwarfield 8170383278 Remove connection::* import. 2026-02-17 21:28:05 -07:00
sam 2e86f68a3c some more review changes 2026-02-17 21:28:05 -07:00
sam 35b2a06e64 some ideas including stub 2026-02-17 21:28:05 -07:00
sam 09985e6031 move audio stuff into trait 2026-02-17 21:28:01 -07:00
sam 056a673bc0 all platform traits implemented & dumb async runtime imports 2026-02-17 21:26:03 -07:00
sam 411d923c2a wip improved trait shit 2026-02-17 21:26:03 -07:00
liamwarfield ff14f577fe Add Platform trait.
I did some more thinking about the whole trait/boundary stuff and reallized that we don't need the GUI to handle the generic-ness of a trait object since only 1 platform will ever be used in a binary. What do you think of the following:

1. Define a platform trait
2. Each platform defines a zero-sized struct implementing the trait (ex `WebPlatform`).
3. Create an ifdef'd type alias on those structs:

```
     // gui/src/platform/mod.rs
     #[cfg(feature = "web")]
     pub type CurrentPlatform = web::WebPlatform;
     #[cfg(feature = "desktop")]
     pub type CurrentPlatform = desktop::DesktopPlatform;
     #[cfg(feature = "mobile")]
     pub type CurrentPlatform = mobile::MobilePlatform;
```

4. Add a compile time assertion that `CurrentPlatform` implements `Platform`.

Pros:

- We don't end up working around async trait objects
- We define what functions are needed for a platform
- We save a little on binary size by avoiding a fully generic solution.

Cons:

- The trait does not really do much other than being a collection of functions.
- In some ways it seems like what we're currently doing but with extra steps.
2026-02-17 21:26:03 -07:00
35 changed files with 1544 additions and 1809 deletions
-1
View File
@@ -1 +0,0 @@
target
-41
View File
@@ -42,47 +42,6 @@ jobs:
path: target/release/mumble-web2-proxy path: target/release/mumble-web2-proxy
retention-days: 5 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: windows_build:
runs-on: windows runs-on: windows
steps: steps:
+1 -1
View File
@@ -6,4 +6,4 @@ server_hash.txt
proxy/bundle proxy/bundle
/config.toml /config.toml
proxy/config.toml proxy/config.toml
*_onnx.tar.gz gui/assets/*_onnx.tar.gz
Generated
+14 -26
View File
@@ -1587,9 +1587,9 @@ dependencies = [
[[package]] [[package]]
name = "dioxus-core-types" name = "dioxus-core-types"
version = "0.7.4" version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b99d7d199aad72431b549759550002e7d72c8a257eba500dca9fbdb2122de103" checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f"
[[package]] [[package]]
name = "dioxus-desktop" name = "dioxus-desktop"
@@ -4220,7 +4220,14 @@ dependencies = [
] ]
[[package]] [[package]]
name = "mumble-web2-client" name = "mumble-web2-common"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "mumble-web2-gui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"android-permissions", "android-permissions",
@@ -4234,8 +4241,9 @@ dependencies = [
"crossbeam-queue", "crossbeam-queue",
"dasp_ring_buffer", "dasp_ring_buffer",
"deep_filter", "deep_filter",
"dioxus",
"dioxus-asset-resolver", "dioxus-asset-resolver",
"dioxus-signals", "dioxus-web",
"etcetera", "etcetera",
"futures", "futures",
"futures-channel", "futures-channel",
@@ -4244,7 +4252,6 @@ dependencies = [
"jni", "jni",
"js-sys", "js-sys",
"lol_html 2.7.0", "lol_html 2.7.0",
"manganis",
"markdown", "markdown",
"merge-io", "merge-io",
"mime_guess", "mime_guess",
@@ -4256,6 +4263,7 @@ dependencies = [
"opus", "opus",
"ordermap", "ordermap",
"reqwest", "reqwest",
"rfd 0.16.0",
"serde", "serde",
"serde-wasm-bindgen", "serde-wasm-bindgen",
"serde_json", "serde_json",
@@ -4271,27 +4279,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "mumble-web2-common"
version = "0.1.0"
dependencies = [
"color-eyre",
"serde",
"tokio",
]
[[package]]
name = "mumble-web2-gui"
version = "0.1.0"
dependencies = [
"color-eyre",
"dioxus",
"dioxus-web",
"mumble-web2-client",
"mumble-web2-common",
"rfd 0.16.0",
]
[[package]] [[package]]
name = "mumble-web2-proxy" name = "mumble-web2-proxy"
version = "0.1.0" version = "0.1.0"
@@ -4300,6 +4287,7 @@ dependencies = [
"hmac-sha256", "hmac-sha256",
"mumble-web2-common", "mumble-web2-common",
"once_cell", "once_cell",
"rand 0.9.2",
"rcgen", "rcgen",
"rustls", "rustls",
"salvo", "salvo",
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["client", "common", "gui", "proxy"] members = ["common", "gui", "proxy"]
[workspace.dependencies] [workspace.dependencies]
serde = { version = "1.0.214", features = ["derive"] } serde = { version = "1.0.214", features = ["derive"] }
-146
View File
@@ -1,146 +0,0 @@
[package]
name = "mumble-web2-client"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web Dependencies
# ================
wasm-bindgen = { version = "^0.2.92", optional = true }
wasm-bindgen-futures = { version = "^0.4.42", optional = true }
wasm-streams = { version = "^0.4.0", optional = true }
serde-wasm-bindgen = { version = "^0.6.5", optional = true }
js-sys = { version = "=0.3.82", optional = true }
web-sys = { version = "=0.3.82", features = [
"WebTransport",
"console",
"WebTransportOptions",
"WebTransportBidirectionalStream",
"WebTransportSendStream",
"WebTransportReceiveStream",
"Navigator",
"MediaDevices",
"AudioDecoder",
"AudioDecoderInit",
"AudioData",
"AudioEncoderConfig",
"AudioDecoderConfig",
"EncodedAudioChunk",
"EncodedAudioChunkInit",
"EncodedAudioChunkType",
"CodecState",
"AudioContext",
"AudioContextOptions",
"MediaStream",
"GainNode",
"MediaStreamAudioSourceNode",
"BaseAudioContext",
"AudioDestinationNode",
"AudioWorkletNode",
"AudioWorklet",
"AudioWorkletProcessor",
"MessagePort",
"MediaStreamConstraints",
"WorkletOptions",
"AudioEncoder",
"AudioEncoderInit",
"AudioDataInit",
"HtmlAnchorElement",
"Url",
"Blob",
"AudioDataCopyToOptions",
"AudioSampleFormat",
"Storage",
], optional = true }
gloo-timers = { version = "^0.3.0", features = ["futures"], optional = true }
tracing-web = { version = "^0.1.3", optional = true }
# Desktop Dependecies
# ===================
tokio = { version = "^1.41.1", features = ["net", "rt"], optional = true }
tokio-rustls = { version = "^0.26.0", optional = true }
opus = { version = "0.3.0", optional = true }
cpal = { version = "0.15.3", optional = true }
dasp_ring_buffer = { version = "0.11.0", optional = true }
etcetera = { version = "0.10.0", optional = true }
# Base Dependencies
# ================
dioxus-signals = "0.7.2"
manganis = "0.7.2"
once_cell = "1.19.0"
asynchronous-codec = { workspace = true }
futures = "^0.3.30"
merge-io = "^0.3.0"
mumble-protocol = { workspace = true }
serde_json = "1"
tokio-util = { version = "^0.7.11", features = ["codec", "compat"] }
byteorder = "1.5"
ogg = "^0.9.1"
ordermap = "^0.5.3"
html-purifier = "^0.3.0"
markdown = "^0.3.0"
futures-channel = "^0.3.30"
mumble-web2-common = { workspace = true }
serde = { workspace = true }
tracing-subscriber = { version = "^0.3.18", features = ["ansi"] }
tracing = "^0.1.40"
color-eyre = "^0.6.3"
crossbeam-queue = "^0.3.11"
lol_html = "^2.2.0"
base64 = "^0.22"
mime_guess = "^2.0.5"
async_cell = "^0.2.3"
reqwest = { version = "^0.12.22", features = ["json"] }
dioxus-asset-resolver = "0.7.2"
# Denoising
# =========
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
"tract",
] }
crossbeam = "0.8.4"
# Android dependencies for requesting permissions
[target.'cfg(target_os = "android")'.dependencies]
android-permissions = "0.1.2"
jni = "0.21.1"
ndk-context = "0.1.1"
[patch.crates-io]
tract-hir = "=0.12.4"
tract-core = "=0.12.4"
tract-onnx = "=0.12.4"
tract-pulse = "=0.12.4"
[features]
web = [
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"serde-wasm-bindgen",
"js-sys",
"web-sys",
"gloo-timers",
"tracing-web",
"deep_filter/wasm",
]
desktop = [
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
"etcetera",
"mumble-web2-common/networking",
]
mobile = [
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
"mumble-web2-common/networking",
]
-215
View File
@@ -1,215 +0,0 @@
use dioxus_signals::{ReadableExt as _, Signal};
use mime_guess::Mime;
use mumble_web2_common::ProxyOverrides;
use ordermap::OrderSet;
use std::collections::{HashMap, HashSet};
use std::{fmt, sync::Arc};
pub type ChannelId = u32;
pub type UserId = u32;
#[derive(Debug)]
pub enum ConnectionState {
Disconnected,
Connecting,
Connected,
Failed(String),
}
#[derive(Debug, Clone)]
pub struct AudioSettings {
pub denoise: bool,
}
#[derive(Debug)]
pub enum Command {
Connect {
address: String,
username: String,
config: ProxyOverrides,
},
SendChat {
markdown: String,
channels: Vec<ChannelId>,
},
SendFile {
bytes: Vec<u8>,
name: String,
mime: Option<Mime>,
channels: Vec<ChannelId>,
},
SetMute {
mute: bool,
},
SetDeaf {
deaf: bool,
},
EnterChannel {
channel: ChannelId,
user: UserId,
},
UpdateAudioSettings(AudioSettings),
Disconnect,
}
#[derive(Default, Debug)]
pub struct UserState {
pub name: String,
pub channel: ChannelId,
pub deaf: bool,
pub mute: bool,
pub suppress: bool,
pub self_deaf: bool,
pub self_mute: bool,
}
#[derive(Debug)]
pub struct Chat {
pub raw: String,
pub dangerous_html: String,
pub sender: Option<UserId>,
}
#[derive(Default, Debug)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
pub position: i32,
}
impl ChannelState {
pub fn update_from_channel_state(
&mut self,
channel_state: &mumble_protocol::control::msgs::ChannelState,
) {
if channel_state.has_position() {
self.position = channel_state.get_position();
}
if channel_state.has_parent() {
self.parent = Some(channel_state.get_parent());
}
if channel_state.has_name() {
self.name = channel_state.get_name().to_string();
}
}
}
#[derive(Default, Debug)]
pub struct ChannelsState {
pub channels: HashMap<ChannelId, ChannelState>,
}
impl ChannelsState {
pub fn update_from_channel_state(
&mut self,
channel_state: &mumble_protocol::control::msgs::ChannelState,
) {
self.channels
.entry(channel_state.get_channel_id())
.or_default()
.update_from_channel_state(channel_state);
self.update_channel_parents();
}
pub fn update_from_channel_remove(
&mut self,
channel_remove: &mumble_protocol::control::msgs::ChannelRemove,
) {
self.channels.remove(&channel_remove.get_channel_id());
self.update_channel_parents();
}
pub fn update_channel_parents(&mut self) {
// Zero out existing children
for state in self.channels.values_mut() {
state.children.clear();
}
let mut to_sort: Vec<(ChannelId, Option<ChannelId>, i32, String)> = Vec::new();
for (id, state) in self.channels.iter() {
// Handle channels with no parent (the root channel)
let Some(parent_id) = state.parent else {
to_sort.push((*id, None, 0, state.name.clone()));
continue;
};
// If a channel has a parent that we haven't gotten a channel
// state packet for, ignore it
if !self.channels.contains_key(&parent_id) {
continue;
}
to_sort.push((*id, Some(parent_id), state.position, state.name.clone()));
}
let pos_name: HashMap<ChannelId, (i32, String)> = self
.channels
.iter()
.map(|(&id, state)| (id, (state.position, state.name.clone())))
.collect();
let mut updated: HashSet<ChannelId> = HashSet::new();
while updated.len() < to_sort.len() {
for &(id, ref parent_id, position, ref name) in &to_sort {
let Some(parent_id) = parent_id else {
updated.insert(id);
continue;
};
if updated.contains(&id) || !updated.contains(&parent_id) {
continue;
}
// Unwrap should never fail here since we pre filter
let parent = self.channels.get_mut(&parent_id).unwrap();
let mut insert_index = parent.children.len();
for (i, &child) in parent.children.iter().enumerate() {
let (p, ref n) = pos_name[&child];
if (position == p && name < n) || p > position {
insert_index = i;
break;
}
}
parent.children.insert_before(insert_index, id);
updated.insert(id);
}
}
}
}
#[derive(Default, Debug)]
pub struct ServerState {
pub channels_state: ChannelsState,
pub users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>,
pub session: Option<UserId>,
}
impl ServerState {
pub fn this_user(&self) -> Option<&UserState> {
self.users.get(&self.session?)
}
}
pub struct State {
pub status: Signal<ConnectionState>,
pub server: Signal<ServerState>,
pub audio: Signal<AudioSettings>,
}
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<State>;
-61
View File
@@ -1,61 +0,0 @@
use crate::app::{Command, SharedState};
use color_eyre::eyre::Error;
use futures_channel::mpsc::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::time::Duration;
/// Desktop platform implementation using Tokio and native audio.
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<ProxyOverrides> {
Ok(ProxyOverrides {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(
_client: &reqwest::Client,
address: &str,
) -> color_eyre::Result<ServerStatus> {
mumble_web2_common::ping_server(address, 64738).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() {
// No-op on desktop
}
}
-79
View File
@@ -1,79 +0,0 @@
use crate::app::{Command, SharedState};
use color_eyre::eyre::Error;
use futures_channel::mpsc::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
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;
type ConfigSystem = super::native_config::NativeConfigSystem;
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
Ok(ProxyOverrides {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(
_client: &reqwest::Client,
address: &str,
) -> color_eyre::Result<ServerStatus> {
mumble_web2_common::ping_server(address, 64738).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();
}
}
-117
View File
@@ -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<Self, Error> {
return Ok(NativeConfigSystem {
config_path: get_config_path()?,
});
}
fn config_get<T>(&self, key: &str) -> Option<T>
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::<T>(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::<T>(default_value)
.expect("Default value could not be parsed"),
)
}
}
}
fn config_set<T>(&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<std::path::PathBuf> {
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<std::path::PathBuf> {
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<String, serde_json::Value> {
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<String, serde_json::Value>) -> 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<serde_json::Value> {
let default_config = platform_default_config();
default_config
.get(key)
.cloned()
.or(super::global_default_config().get(key).cloned())
}
fn platform_default_config() -> HashMap<String, serde_json::Value> {
serde_json::json!({})
.as_object()
.unwrap()
.clone()
.into_iter()
.collect()
}
-13
View File
@@ -1,13 +0,0 @@
mod app;
mod effects;
mod imp;
mod mainloop;
mod msghtml;
pub use app::*;
pub use imp::*;
pub use mainloop::*;
pub use mime_guess;
pub use reqwest;
pub const VERSION: Option<&str> = option_env!("MUMBLE_WEB2_VERSION");
-5
View File
@@ -3,10 +3,5 @@ name = "mumble-web2-common"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[features]
networking = ["dep:tokio", "dep:color-eyre"]
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
tokio = { version = "1", features = ["net", "time"], optional = true }
color-eyre = { version = "0.6", optional = true }
+1 -59
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProxyOverrides { pub struct ClientConfig {
pub proxy_url: Option<String>, pub proxy_url: Option<String>,
pub cert_hash: Option<Vec<u8>>, pub cert_hash: Option<Vec<u8>>,
pub any_server: bool, pub any_server: bool,
@@ -16,61 +16,3 @@ pub struct ServerStatus {
pub max_users: Option<u32>, pub max_users: Option<u32>,
pub bandwidth: Option<u32>, pub bandwidth: Option<u32>,
} }
/// Mumble UDP ping protocol.
///
/// Send a 12-byte packet: 4 zero bytes + 8-byte identifier.
/// Receive a 24-byte response: 4 bytes version + 8 bytes identifier echo
/// + 4 bytes current_users + 4 bytes max_users + 4 bytes bandwidth.
#[cfg(feature = "networking")]
pub async fn ping_server(address: &str, port: u16) -> color_eyre::Result<ServerStatus> {
use color_eyre::eyre::{bail, eyre};
use std::time::Duration;
use tokio::net::{lookup_host, UdpSocket};
let dest = lookup_host(format!("{}:{}", address, port))
.await?
.next()
.ok_or_else(|| 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?;
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"),
}
}
+9 -7
View File
@@ -1,12 +1,14 @@
localhost:64444 { localhost:64444 {
tls internal tls internal
# Proxy /config path to mumble-web2-proxy # Proxy /config path to mumble-web2-proxy
reverse_proxy /overrides http://127.0.0.1:4400 reverse_proxy /config http://127.0.0.1:4400
# Proxy /status path to mumble-web2-proxy # Proxy /status path to mumble-web2-proxy
reverse_proxy /status http://127.0.0.1:4400 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
} }
-21
View File
@@ -1,21 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
IMAGE_NAME="mumble-web2/android-release-builder:local"
TARGET="${1:-aarch64-linux-android}"
echo "==> Building Android builder Docker image..."
docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/android-release-builder.Dockerfile" "$PROJECT_ROOT"
echo "==> Building Android APK (target: $TARGET)..."
docker run --rm \
-v "$PROJECT_ROOT:/app" \
-w /app \
"$IMAGE_NAME" \
dx build --platform android --target "$TARGET" --release -p mumble-web2-gui
echo "==> Done! APK should be at:"
echo " target/dx/mumble-web2-gui/release/android/app/app/build/outputs/apk/debug/app-debug.apk"
+1 -1
View File
@@ -20,7 +20,7 @@ services:
# volumes: # volumes:
# - ..:/app # - ..:/app
# environment: # environment:
# - MUMBLE_WEB2_PROXY_OVERRIDES_URL=https://localhost:64444/overrides # - MUMBLE_WEB2_GUI_CONFIG_URL=https://localhost:64444/config
# stdin_open: true # stdin_open: true
# tty: true # tty: true
# command: > # command: >
+129 -6
View File
@@ -4,11 +4,103 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
# Web Dependencies
# ================
dioxus-web = { version = "0.7.1", optional = true }
wasm-bindgen = { version = "^0.2.92", optional = true }
wasm-bindgen-futures = { version = "^0.4.42", optional = true }
wasm-streams = { version = "^0.4.0", optional = true }
serde-wasm-bindgen = { version = "^0.6.5", optional = true }
js-sys = { version = "^0.3.70", optional = true }
web-sys = { version = "^0.3.72", features = [
"WebTransport",
"console",
"WebTransportOptions",
"WebTransportBidirectionalStream",
"WebTransportSendStream",
"WebTransportReceiveStream",
"Navigator",
"MediaDevices",
"AudioDecoder",
"AudioDecoderInit",
"AudioData",
"AudioEncoderConfig",
"AudioDecoderConfig",
"EncodedAudioChunk",
"EncodedAudioChunkInit",
"EncodedAudioChunkType",
"CodecState",
"AudioContext",
"AudioContextOptions",
"MediaStream",
"GainNode",
"MediaStreamAudioSourceNode",
"BaseAudioContext",
"AudioDestinationNode",
"AudioWorkletNode",
"AudioWorklet",
"AudioWorkletProcessor",
"MessagePort",
"MediaStreamConstraints",
"WorkletOptions",
"AudioEncoder",
"AudioEncoderInit",
"AudioDataInit",
"HtmlAnchorElement",
"Url",
"Blob",
"AudioDataCopyToOptions",
"AudioSampleFormat",
"Storage",
], optional = true }
gloo-timers = { version = "^0.3.0", features = ["futures"], optional = true }
tracing-web = { version = "^0.1.3", optional = true }
# Desktop Dependecies
# ===================
tokio = { version = "^1.41.1", features = ["net", "rt"], optional = true }
tokio-rustls = { version = "^0.26.0", optional = true }
opus = { version = "0.3.0", optional = true }
cpal = { version = "0.15.3", optional = true }
dasp_ring_buffer = { version = "0.11.0", optional = true }
etcetera = { version = "0.10.0", optional = true }
# Base Dependencies
# ================
dioxus = { version = "0.7.2" } dioxus = { version = "0.7.2" }
dioxus-web = { version = "0.7.2", optional = true } once_cell = "1.19.0"
mumble-web2-client = { version = "0.1.0", path = "../client" } asynchronous-codec = { workspace = true }
mumble-web2-common = { version = "0.1.0", path = "../common" } futures = "^0.3.30"
merge-io = "^0.3.0"
mumble-protocol = { workspace = true }
serde_json = "1"
tokio-util = { version = "^0.7.11", features = ["codec", "compat"] }
byteorder = "1.5"
ogg = "^0.9.1"
ordermap = "^0.5.3"
html-purifier = "^0.3.0"
markdown = "^0.3.0"
futures-channel = "^0.3.30"
mumble-web2-common = { workspace = true }
serde = { workspace = true }
tracing-subscriber = { version = "^0.3.18", features = ["ansi"] }
tracing = "^0.1.40"
color-eyre = "^0.6.3" color-eyre = "^0.6.3"
crossbeam-queue = "^0.3.11"
lol_html = "^2.2.0"
base64 = "^0.22"
mime_guess = "^2.0.5"
async_cell = "^0.2.3"
reqwest = { version = "^0.12.22", features = ["json"] }
dioxus-asset-resolver = "0.7.2"
# Denoising
# =========
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
"tract",
] }
crossbeam = "0.8.4"
# Platform Integration # Platform Integration
# ==================== # ====================
@@ -16,19 +108,50 @@ color-eyre = "^0.6.3"
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies] [target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true } rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true }
# Android dependencies for requesting permissions
[target.'cfg(target_os = "android")'.dependencies]
android-permissions = "0.1.2"
jni = "0.21.1"
ndk-context = "0.1.1"
[patch.crates-io]
tract-hir = "=0.12.4"
tract-core = "=0.12.4"
tract-onnx = "=0.12.4"
tract-pulse = "=0.12.4"
[features] [features]
web = [ web = [
"dioxus/web", "dioxus/web",
"dioxus-web", "dioxus-web",
"mumble-web2-client/web", "wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"serde-wasm-bindgen",
"js-sys",
"web-sys",
"gloo-timers",
"tracing-web",
"deep_filter/wasm",
"rfd", "rfd",
] ]
desktop = [ desktop = [
"dioxus/desktop", "dioxus/desktop",
"mumble-web2-client/desktop", "tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
"rfd/xdg-portal", "rfd/xdg-portal",
"etcetera",
] ]
mobile = [ mobile = [
"dioxus/mobile", "dioxus/mobile",
"mumble-web2-client/mobile" "tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
] ]
-1
View File
@@ -16,7 +16,6 @@ body {
} }
#main { #main {
visibility: visible;
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
View File
+881
View File
@@ -0,0 +1,881 @@
#![allow(non_snake_case)]
use dioxus::prelude::*;
use mime_guess::Mime;
use mumble_web2_common::{ClientConfig, ServerStatus};
use ordermap::OrderSet;
use std::collections::{HashMap, HashSet};
use crate::imp::{Platform, PlatformInterface as _};
pub type ChannelId = u32;
pub type UserId = u32;
pub enum ConnectionState {
Disconnected,
Connecting,
Connected,
Failed(String),
}
#[derive(Debug)]
pub enum Command {
Connect {
address: String,
username: String,
config: ClientConfig,
},
SendChat {
markdown: String,
channels: Vec<ChannelId>,
},
SendFile {
bytes: Vec<u8>,
name: String,
mime: Option<Mime>,
channels: Vec<ChannelId>,
},
SetMute {
mute: bool,
},
SetDeaf {
deaf: bool,
},
EnterChannel {
channel: ChannelId,
user: UserId,
},
UpdateMicEffects {
denoise: bool,
},
Disconnect,
}
use Command::*;
use ConnectionState::*;
#[derive(Default)]
pub struct UserState {
pub name: String,
pub channel: ChannelId,
pub deaf: bool,
pub mute: bool,
pub suppress: bool,
pub self_deaf: bool,
pub self_mute: bool,
}
impl UserState {
pub fn icon(&self) -> UserIcon {
if self.deaf || self.self_deaf {
UserIcon::Deafened
} else if self.mute || self.self_mute {
UserIcon::Muted
} else if self.suppress {
UserIcon::Suppressed
} else {
UserIcon::Normal
}
}
}
pub struct Chat {
pub raw: String,
pub dangerous_html: String,
pub sender: Option<UserId>,
}
#[derive(Default)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
pub position: i32,
}
impl ChannelState {
pub fn update_from_channel_state(
&mut self,
channel_state: &mumble_protocol::control::msgs::ChannelState,
) {
if channel_state.has_position() {
self.position = channel_state.get_position();
}
if channel_state.has_parent() {
self.parent = Some(channel_state.get_parent());
}
if channel_state.has_name() {
self.name = channel_state.get_name().to_string();
}
}
}
#[derive(Default)]
pub struct ChannelsState {
pub channels: HashMap<ChannelId, ChannelState>,
}
impl ChannelsState {
pub fn update_from_channel_state(
&mut self,
channel_state: &mumble_protocol::control::msgs::ChannelState,
) {
self.channels
.entry(channel_state.get_channel_id())
.or_default()
.update_from_channel_state(channel_state);
self.update_channel_parents();
}
pub fn update_from_channel_remove(
&mut self,
channel_remove: &mumble_protocol::control::msgs::ChannelRemove,
) {
self.channels.remove(&channel_remove.get_channel_id());
self.update_channel_parents();
}
pub fn update_channel_parents(&mut self) {
// Zero out existing children
for state in self.channels.values_mut() {
state.children.clear();
}
let mut to_sort: Vec<(ChannelId, Option<ChannelId>, i32, String)> = Vec::new();
for (id, state) in self.channels.iter() {
// Handle channels with no parent (the root channel)
let Some(parent_id) = state.parent else {
to_sort.push((*id, None, 0, state.name.clone()));
continue;
};
// If a channel has a parent that we haven't gotten a channel
// state packet for, ignore it
if !self.channels.contains_key(&parent_id) {
continue;
}
to_sort.push((*id, Some(parent_id), state.position, state.name.clone()));
}
let pos_name: HashMap<ChannelId, (i32, String)> = self
.channels
.iter()
.map(|(&id, state)| (id, (state.position, state.name.clone())))
.collect();
let mut updated: HashSet<ChannelId> = HashSet::new();
while updated.len() < to_sort.len() {
for &(id, ref parent_id, position, ref name) in &to_sort {
let Some(parent_id) = parent_id else {
updated.insert(id);
continue;
};
if updated.contains(&id) || !updated.contains(&parent_id) {
continue;
}
// Unwrap should never fail here since we pre filter
let parent = self.channels.get_mut(&parent_id).unwrap();
let mut insert_index = parent.children.len();
for (i, &child) in parent.children.iter().enumerate() {
let (p, ref n) = pos_name[&child];
if (position == p && name < n) || p > position {
insert_index = i;
break;
}
}
parent.children.insert_before(insert_index, id);
updated.insert(id);
}
}
}
}
#[derive(Default)]
pub struct ServerState {
pub channels_state: ChannelsState,
pub users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>,
pub session: Option<UserId>,
}
impl ServerState {
pub fn this_user(&self) -> Option<&UserState> {
self.users.get(&self.session?)
}
}
pub struct State {
pub status: GlobalSignal<ConnectionState>,
pub server: GlobalSignal<ServerState>,
}
pub static STATE: State = State {
status: Signal::global(|| Disconnected),
server: Signal::global(|| Default::default()),
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum UserIcon {
Normal,
Muted,
Deafened,
Suppressed,
None,
}
impl UserIcon {
pub fn url(self) -> Option<Asset> {
// speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/
// mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/
use UserIcon::*;
Some(match self {
Normal => asset!("assets/mic-svgrepo-com.svg"),
Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"),
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
None => return Option::None,
})
}
}
#[component]
pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
let color = match icon {
UserIcon::Normal => "var(--accent-normal)",
UserIcon::Muted => "var(--accent-muted)",
UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)",
UserIcon::None => "var(--accent-normal)",
};
rsx!(
div {
class: match isself { true => "userpil is_self", false => "userpil" },
style: "background-color: {color}",
{ icon.url().map(|url| rsx!(img { src: url })) }
"\u{00A0}{name}\u{00A0}"
}
)
}
#[component]
pub fn User(id: UserId) -> Element {
let server = STATE.server.read();
match server.users.get(&id) {
Some(state) => rsx!(UserPill {
name: state.name.clone(),
icon: state.icon(),
isself: server.session.unwrap() == id,
}),
None => rsx!(UserPill {
name: format!("unknown user ({id})"),
icon: UserIcon::None,
isself: false,
}),
}
}
#[component]
pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
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}");
};
let mut open = use_signal(|| true);
let has_children = !state.users.is_empty() || !state.children.is_empty();
rsx!(
div {
class: "channel_details",
div {
class: "channel_header",
// Arrow: only toggles open
if has_children {
span {
class: "channel_arrow",
onclick: move |evt| {
evt.stop_propagation();
evt.prevent_default();
let mut w = open.write();
*w = !*w;
},
if *open.read() { "" } else { "" }
}
} else {
span {
class: "channel_arrow channel_arrow--placeholder",
" "
}
}
// Clickable row area (everything except the arrow)
div {
class: "channel_row_click",
ondblclick: move |evt| {
evt.stop_propagation();
evt.prevent_default();
net.send(EnterChannel { channel: id, user })
},
// remove dblclick from the inner span
span {
class: "channel_title",
"{state.name}"
}
// if you add icons/badges later, put them here too
}
}
if *open.read() && has_children {
div {
class: "channel_children",
for id in state.users.iter() {
User { id: *id }
}
for child in state.children.iter() {
Channel { id: *child }
}
}
}
}
)
}
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn pick_and_send_file(net: &Coroutine<Command>) {
let channels = if let Some(user) = STATE.server.read().this_user() {
vec![user.channel]
} else {
return;
};
let dialog = rfd::AsyncFileDialog::new().pick_file();
let sender = net.tx();
spawn(async move {
let Some(handle) = dialog.await else { return };
let name = handle.file_name();
let bytes = handle.read().await;
let mime = mime_guess::from_path(&name).first();
let _ = sender.unbounded_send(SendFile {
bytes,
name,
mime,
channels,
});
});
}
#[cfg(not(any(feature = "desktop", feature = "web")))]
pub fn pick_and_send_file(net: &Coroutine<Command>) {}
#[component]
pub fn ChatView() -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let mut draft = use_signal(|| "".to_string());
let mut do_send = move || {
if let Some(user) = STATE.server.read().this_user() {
net.send(SendChat {
markdown: draft.write().split_off(0),
channels: vec![user.channel],
});
}
};
rsx!(
div {
class: "chat_panel",
div {
class: "chat_history",
for chat in server.chat.iter() {
div {
class: "chat_message",
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
UserPill {
name: sender.name.clone(),
icon: UserIcon::None,
isself: false,
}
}
span {
dangerous_inner_html: "{chat.dangerous_html}",
}
}
}
}
div {
class: "chat_box_wrapper",
div {
class: "chat_box",
input {
placeholder: "say something",
value: "{draft.read()}",
oninput: move |evt| draft.set(evt.value().clone()),
onkeypress: move |evt: Event<KeyboardData>| {
if evt.code() == Code::Enter && evt.modifiers().is_empty() {
do_send();
}
}
}
div {
span {
onclick: move |_| pick_and_send_file(&net),
class: "material-symbols-outlined",
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;",
"attach_file",
}
}
div {
span {
onclick: move |_| do_send(),
class: "material-symbols-outlined",
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;",
"send",
}
}
}
//button {
// onclick: move |_| do_send(),
// "Send"
//}
}
}
)
}
#[component]
pub fn ControlView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let status = &STATE.status;
let server = STATE.server.read();
let Some(&UserState {
deaf,
self_deaf,
mute,
suppress,
self_mute,
ref name,
channel,
..
}) = server.this_user()
else {
return rsx!();
};
let current_channel_name = server.channels_state.channels[&channel].name.clone();
let proxy_url = config
.read_unchecked()
.as_ref()
.and_then(|gui_config| gui_config.proxy_url.clone());
let connecting_color = "yellow";
let connected_color = "oklch(0.55 0.1184 141.35)";
let disconnected_color = "gray";
let failed_color = "red";
let connection_status = match &*status.read() {
Connecting => rsx! {
div {
class: "connection_status",
style: "color: {connecting_color};",
div {
span {
class: "material-symbols-outlined",
"signal_cellular_alt_2_bar"
}
span {
class: "status_text",
" Connecting"
}
}
}
},
Connected => rsx! {
div {
class: "connection_status",
div {
style: "color: {connected_color};",
span {
class: "material-symbols-outlined",
"signal_cellular_alt"
}
span {
class: "status_text",
" Connected"
}
}
div {
class: "channel_text",
span { "{current_channel_name}" }
}
}
},
Disconnected => rsx! {
div {
class: "connection_status",
style: "color: {disconnected_color};",
div {
span {
class: "material-symbols-outlined",
"signal_disconnected"
}
span {
class: "status_text",
" Disconnected"
}
}
}
},
Failed(_) => rsx! {
div {
class: "connection_status",
style: "color: {failed_color};",
div {
span {
class: "material-symbols-outlined",
"error"
}
span {
class: "status_text",
" Failed"
}
}
}
},
};
let denoise = use_signal(|| false);
rsx!(
// Server control
div {
class: "button_row",
div {
{connection_status}
}
span { class: "spacer" }
button {
class: "toggle_button",
onclick: move |_| net.send(Disconnect),
span {
class: "material-symbols-outlined",
"signal_disconnected"
}
}
}
hr { style: "width: 100%;" }
// User control
div {
class: "button_row",
button {
class: "user_edit_button",
span {
class: "material-symbols-outlined",
style: "color: oklch(0.65 0.2245 28.06);",
"person_edit"
}
}
div {
class: "user_info",
div {
span { class: "user_name", "{name}" }
}
div {
span { class: "user_data", "some data" }
}
}
span { class: "spacer" }
button {
class: match denoise() {
true => "toggle_button is_on",
false => "toggle_button",
},
role: "switch",
aria_checked: denoise(),
onclick: move |_| {
let new_denoise = !denoise();
*denoise.write_unchecked() = new_denoise;
net.send(UpdateMicEffects { denoise: new_denoise })
},
match denoise() {
true => rsx!(span { class: "material-symbols-outlined", "cadence"}),
false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}),
}
}
button {
class: match mute || suppress || self_mute {
true => "toggle_button is_on",
false => "toggle_button",
},
role: "switch",
aria_checked: mute || suppress || self_mute,
disabled: mute || suppress,
onclick: move |_| net.send(SetMute { mute: !self_mute }),
match mute || suppress || self_mute {
true => rsx!(span { class: "material-symbols-outlined", "mic_off"}),
false => rsx!(span { class: "material-symbols-outlined", "mic"}),
}
}
button {
class: match deaf || self_deaf {
true => "toggle_button in_on",
false => "toggle_button",
},
role: "switch",
aria_checked: deaf || self_deaf,
disabled: deaf,
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
match deaf || self_deaf {
true => rsx!(span { class: "material-symbols-outlined", "volume_off"}),
false => rsx!(span { class: "material-symbols-outlined", "volume_up"}),
}
}
}
)
}
#[component]
pub fn ServerView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let Some(&UserState {
deaf,
self_deaf,
mute,
self_mute,
..
}) = server.this_user()
else {
return rsx!();
};
rsx!(
div {
class: "server_grid",
div {
class: "server_channel_box",
for (id, state) in server.channels_state.channels.iter() {
if state.parent.is_none() {
Channel { id: *id }
}
}
}
div {
class: "server_chat_box",
ChatView {}
}
div {
class: "server_control_box",
ControlView { config }
}
}
)
}
#[component]
pub fn LoginView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
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 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 previous_username = Platform::load_username();
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
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! {
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}"
}
}
),
Connected => unreachable!(),
};
let version = option_env!("MUMBLE_WEB2_VERSION");
rsx!(
div {
class: "login",
h1 {
"Mumble Web"
match version {
Some(v) => rsx!(" " span { 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 {
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()),
}
}
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}
}
}
}
)
// 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}
// }
// )
}
pub fn app() -> Element {
static STYLE: Asset = asset!("/assets/main.scss");
use_coroutine(|rx: UnboundedReceiver<Command>| 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 { config }),
_ => rsx!(LoginView { config }),
}
)
}
+16 -6
View File
@@ -1,8 +1,8 @@
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview}; use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
use df::tract::{DfParams, DfTract, RuntimeParams}; use df::tract::{DfParams, DfTract, RuntimeParams};
use dioxus::prelude::{asset, manganis, Asset};
use dioxus_asset_resolver::read_asset_bytes; use dioxus_asset_resolver::read_asset_bytes;
use manganis::{asset, Asset};
use std::cell::RefCell; use std::cell::RefCell;
use std::sync::Arc; use std::sync::Arc;
use tracing::{error, info}; use tracing::{error, info};
@@ -44,12 +44,11 @@ fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract)
let cell = Arc::new(AtomicCell::new(None)); let cell = Arc::new(AtomicCell::new(None));
let cell_task = cell.clone(); let cell_task = cell.clone();
*state = DenoisingModelState::Downloading(cell); *state = DenoisingModelState::Downloading(cell);
let model = DF_MODEL.to_string();
spawn.spawn(async move { spawn.spawn(async move {
let model_bytes = match read_asset_bytes(&model).await { let model_bytes = match read_asset_bytes(&DF_MODEL).await {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
error!("could not read denoising model from \"{model}\": {e:?}"); error!("could not read denoising model from \"{DF_MODEL}\": {e:?}");
return; return;
} }
}; };
@@ -97,9 +96,20 @@ pub struct AudioProcessor {
} }
impl AudioProcessor { impl AudioProcessor {
pub fn new(denoise: bool) -> Self { pub fn new_plain() -> Self {
AudioProcessor { AudioProcessor {
denoise, denoise: false,
spawn: SpawnHandle::current(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
hold_samples: 0,
}
}
pub fn new_denoising() -> Self {
AudioProcessor {
denoise: true,
spawn: SpawnHandle::current(), spawn: SpawnHandle::current(),
buffer: Vec::new(), buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR, noise_floor: DEFAULT_NOISE_FLOOR,
@@ -1,6 +1,6 @@
use crate::app::{Command, SharedState}; use crate::app::Command;
use color_eyre::eyre::Error; use color_eyre::eyre::{bail, Error};
use futures_channel::mpsc::UnboundedReceiver; use dioxus::hooks::UnboundedReceiver;
use mumble_protocol::control::ClientControlCodec; use mumble_protocol::control::ClientControlCodec;
use std::net::ToSocketAddrs; use std::net::ToSocketAddrs;
use std::sync::Arc; use std::sync::Arc;
@@ -8,13 +8,13 @@ use tokio::net::TcpStream;
use tokio_rustls::rustls; use tokio_rustls::rustls;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier}; use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime}; 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::rustls::DigitallySignedStruct;
use tokio_rustls::TlsConnector; use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _}; use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{info, instrument}; use tracing::{info, instrument};
use mumble_web2_common::ProxyOverrides; use mumble_web2_common::{ClientConfig, ServerStatus};
#[derive(Debug)] #[derive(Debug)]
struct NoCertificateVerification; struct NoCertificateVerification;
@@ -73,12 +73,11 @@ pub async fn network_connect(
address: String, address: String,
username: String, username: String,
event_rx: &mut UnboundedReceiver<Command>, event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides, gui_config: &ClientConfig,
state: SharedState,
) -> Result<(), Error> { ) -> Result<(), Error> {
info!("connecting"); info!("connecting");
let config = ClientConfig::builder() let config = RlsClientConfig::builder()
.dangerous() .dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) .with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth(); .with_no_client_auth();
@@ -103,9 +102,11 @@ pub async fn network_connect(
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec); let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec); let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
let (outgoing_send, outgoing_recv) = futures_channel::mpsc::unbounded(); crate::network_loop(username, event_rx, reader, writer).await
spawn(crate::sender_loop(outgoing_recv, writer)); }
crate::network_loop(username, state, event_rx, outgoing_send, reader).await
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
bail!("status not supported on desktop yet")
} }
#[allow(unused)] #[allow(unused)]
+108
View File
@@ -0,0 +1,108 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::collections::HashMap;
use std::time::Duration;
/// Desktop platform implementation using Tokio and native audio.
pub struct DesktopPlatform;
impl super::PlatformInterface for DesktopPlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
fn load_username() -> Option<String> {
let config = load_config_map();
config.get("username").cloned()
}
fn load_server_url() -> Option<String> {
let config = load_config_map();
config.get("server").cloned()
}
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<()> {
let mut config = load_config_map();
config.insert("server".to_string(), server.to_string());
save_config_map(&config).ok()
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
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<ServerStatus> {
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() {
// No-op on desktop
}
}
fn get_config_path() -> std::path::PathBuf {
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "com".to_string(),
author: "Ohea Corp".to_string(),
app_name: "Mumble Web2".to_string(),
})
.expect("failed to choose app strategy");
strategy.config_dir().join("config.json")
}
fn load_config_map() -> HashMap<String, String> {
let config_path = get_config_path();
match std::fs::read_to_string(&config_path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> {
let config_path = get_config_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(config)?;
std::fs::write(&config_path, contents)?;
Ok(())
}
+21 -47
View File
@@ -4,12 +4,10 @@
//! The traits make the platform boundary explicit and provide compile-time verification. //! The traits make the platform boundary explicit and provide compile-time verification.
#![allow(async_fn_in_trait)] #![allow(async_fn_in_trait)]
use crate::app::{Command, SharedState}; use crate::{app::Command, effects::AudioProcessor};
use crate::effects::AudioProcessor;
use color_eyre::eyre::Error; use color_eyre::eyre::Error;
use futures_channel::mpsc::UnboundedReceiver; use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus}; use mumble_web2_common::{ClientConfig, ServerStatus};
use std::collections::HashMap;
use std::future::Future; use std::future::Future;
use std::time::Duration; use std::time::Duration;
@@ -52,24 +50,11 @@ pub trait AudioPlayerInterface {
fn play_opus(&mut self, payload: &[u8]); fn play_opus(&mut self, payload: &[u8]);
} }
pub trait ConfigSystemInterface: Sized + Clone {
fn new() -> Result<Self, Error>;
fn config_get<T>(&self, key: &str) -> Option<T>
where
T: serde::de::DeserializeOwned;
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize;
}
/// This is the main trait that each platform must implement. It combines all /// This is the main trait that each platform must implement. It combines all
/// platform-specific functionality into a single interface, providing compile-time /// platform-specific functionality into a single interface, providing compile-time
/// verification that all platforms implement the required functionality. /// verification that all platforms implement the required functionality.
pub trait PlatformInterface { pub trait PlatformInterface {
type AudioSystem: AudioSystemInterface; type AudioSystem: AudioSystemInterface;
type ConfigSystem: ConfigSystemInterface;
/// Initialize logging for the platform. /// Initialize logging for the platform.
fn init_logging(); fn init_logging();
@@ -82,22 +67,28 @@ pub trait PlatformInterface {
address: String, address: String,
username: String, username: String,
event_rx: &mut UnboundedReceiver<Command>, event_rx: &mut UnboundedReceiver<Command>,
proxy_overrides: &ProxyOverrides, gui_config: &ClientConfig,
state: SharedState,
) -> impl Future<Output = Result<(), Error>>; ) -> impl Future<Output = Result<(), Error>>;
/// Get server status (user count, version, etc.) for the given address. /// Get server status (user count, version, etc.).
///
/// On web, this goes through the proxy's /status endpoint and ignores `address`
/// (the proxy is bound to a specific server). On desktop/mobile, this pings the
/// given address directly via UDP.
fn get_status( fn get_status(
client: &reqwest::Client, client: &reqwest::Client,
address: &str,
) -> impl Future<Output = color_eyre::Result<ServerStatus>>; ) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
/// Load the proxy overrides (proxy URL, cert hash, etc.). /// Load the proxy overrides (proxy URL, cert hash, etc.).
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>>; fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>>;
/// Load saved username.
fn load_username() -> Option<String>;
/// Load saved server URL.
fn load_server_url() -> Option<String>;
/// Save the default username.
fn set_default_username(username: &str) -> Option<()>;
/// Save the default server URL.
fn set_default_server(server: &str) -> Option<()>;
/// Async sleep for the given duration. /// Async sleep for the given duration.
fn sleep(duration: Duration) -> impl Future<Output = ()>; fn sleep(duration: Duration) -> impl Future<Output = ()>;
@@ -107,21 +98,15 @@ pub trait PlatformInterface {
// Platform Modules // Platform Modules
// ============================================================================ // ============================================================================
mod stub;
#[cfg(any(feature = "desktop", feature = "mobile"))] #[cfg(any(feature = "desktop", feature = "mobile"))]
mod connect; mod connect;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_config;
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
mod desktop; mod desktop;
#[cfg(feature = "mobile")] #[cfg(feature = "mobile")]
mod mobile; mod mobile;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
mod stub;
#[cfg(feature = "web")] #[cfg(feature = "web")]
mod web; mod web;
@@ -148,8 +133,6 @@ pub type Platform = stub::StubPlatform;
pub type AudioSystem = <Platform as PlatformInterface>::AudioSystem; pub type AudioSystem = <Platform as PlatformInterface>::AudioSystem;
pub type AudioPlayer = <AudioSystem as AudioSystemInterface>::AudioPlayer; pub type AudioPlayer = <AudioSystem as AudioSystemInterface>::AudioPlayer;
pub type ConfigSystem = <Platform as PlatformInterface>::ConfigSystem;
// ======================== // ========================
// Platform Async Runtime // Platform Async Runtime
// ======================== // ========================
@@ -181,12 +164,3 @@ const _: () = {
let _ = assert_platform::<mobile::MobilePlatform>; let _ = assert_platform::<mobile::MobilePlatform>;
let _ = assert_platform::<stub::StubPlatform>; let _ = assert_platform::<stub::StubPlatform>;
}; };
fn global_default_config() -> HashMap<String, serde_json::Value> {
serde_json::json!({})
.as_object()
.unwrap()
.clone()
.into_iter()
.collect()
}
@@ -112,7 +112,7 @@ impl super::AudioSystemInterface for NativeAudioSystem {
); );
let mut encoder = let mut encoder =
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?; opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
let mut current_processor = AudioProcessor::new(false); let mut current_processor = AudioProcessor::new_plain();
let mut output_buffer = Vec::new(); let mut output_buffer = Vec::new();
let processors = self.processors.clone(); let processors = self.processors.clone();
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}"); let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
+21 -31
View File
@@ -1,16 +1,15 @@
/// Stub implementation of the platform interface, so that we can /// Stub implementation of the platform interface, so that we can
/// `cargo check` without any --feature flags. /// `cargo check` without any --feature flags.
use crate::{app::SharedState, effects::AudioProcessor}; use crate::effects::AudioProcessor;
use color_eyre::eyre::Error; use color_eyre::eyre::Error;
use futures_channel::mpsc::UnboundedReceiver; use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus}; use mumble_web2_common::{ClientConfig, ServerStatus};
use std::future::Future; use std::future::Future;
pub struct StubPlatform; pub struct StubPlatform;
impl super::PlatformInterface for StubPlatform { impl super::PlatformInterface for StubPlatform {
type AudioSystem = StubAudioSystem; type AudioSystem = StubAudioSystem;
type ConfigSystem = StubConfigSystem;
fn init_logging() { fn init_logging() {
panic!("stubbed platform") panic!("stubbed platform")
@@ -24,23 +23,37 @@ impl super::PlatformInterface for StubPlatform {
_address: String, _address: String,
_username: String, _username: String,
_event_rx: &mut UnboundedReceiver<crate::app::Command>, _event_rx: &mut UnboundedReceiver<crate::app::Command>,
_overrides: &ProxyOverrides, _gui_config: &ClientConfig,
_state: SharedState,
) -> impl Future<Output = Result<(), Error>> { ) -> impl Future<Output = Result<(), Error>> {
async { panic!("stubbed platform") } async { panic!("stubbed platform") }
} }
fn get_status( fn get_status(
_client: &reqwest::Client, _client: &reqwest::Client,
_address: &str,
) -> impl Future<Output = color_eyre::Result<ServerStatus>> { ) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
async { panic!("stubbed platform") } async { panic!("stubbed platform") }
} }
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>> { fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>> {
async { panic!("stubbed platform") } async { panic!("stubbed platform") }
} }
fn load_username() -> Option<String> {
panic!("stubbed platform")
}
fn load_server_url() -> Option<String> {
panic!("stubbed platform")
}
fn set_default_username(_username: &str) -> Option<()> {
panic!("stubbed platform")
}
fn set_default_server(_server: &str) -> Option<()> {
panic!("stubbed platform")
}
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> { fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
async { panic!("stubbed platform") } async { panic!("stubbed platform") }
} }
@@ -79,29 +92,6 @@ impl super::AudioPlayerInterface for StubAudioPlayer {
} }
} }
#[derive(Clone)]
pub struct StubConfigSystem;
impl super::ConfigSystemInterface for StubConfigSystem {
fn new() -> Result<Self, Error> {
panic!("stubbed platform")
}
fn config_get<T>(&self, key: &str) -> Option<T>
where
T: serde::de::DeserializeOwned,
{
panic!("stubbed platform")
}
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize,
{
panic!("stubbed platform")
}
}
#[allow(unused)] #[allow(unused)]
pub struct SpawnHandle; pub struct SpawnHandle;
+46 -95
View File
@@ -1,15 +1,13 @@
use crate::app::{Command, SharedState}; use crate::app::Command;
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState}; use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{bail, eyre, Error}; use color_eyre::eyre::{bail, eyre, Error};
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use futures_channel::mpsc::UnboundedReceiver; use dioxus::prelude::*;
use gloo_timers::future::TimeoutFuture; use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array; use js_sys::Float32Array;
use manganis::asset;
use mumble_protocol::control::ClientControlCodec; use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ProxyOverrides, ServerStatus}; use mumble_web2_common::{ClientConfig, ServerStatus};
use reqwest::Url; use reqwest::Url;
use std::collections::HashMap;
use std::future::Future; use std::future::Future;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -64,7 +62,6 @@ pub struct WebPlatform;
impl super::PlatformInterface for WebPlatform { impl super::PlatformInterface for WebPlatform {
type AudioSystem = WebAudioSystem; type AudioSystem = WebAudioSystem;
type ConfigSystem = WebConfigSystem;
fn init_logging() { fn init_logging() {
// copied from tracing_web example usage // copied from tracing_web example usage
@@ -92,35 +89,56 @@ impl super::PlatformInterface for WebPlatform {
// No-op on web // No-op on web
} }
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> { async fn load_config() -> color_eyre::Result<ClientConfig> {
let overrides = match option_env!("MUMBLE_WEB2_PROXY_OVERRIDES_URL") { let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
Some(url) => Url::parse(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? .await?
.json::<ProxyOverrides>() .json::<ClientConfig>()
.await?; .await?;
Ok(config) Ok(config)
} }
fn load_username() -> Option<String> {
web_sys::window()
.unwrap()
.local_storage()
.ok()??
.get_item("username")
.ok()?
}
fn load_server_url() -> Option<String> {
None
}
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<()> {
None
}
async fn network_connect( async fn network_connect(
address: String, address: String,
username: String, username: String,
event_rx: &mut UnboundedReceiver<Command>, event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides, gui_config: &ClientConfig,
state: SharedState,
) -> Result<(), Error> { ) -> Result<(), Error> {
network_connect(address, username, event_rx, overrides, state).await network_connect(address, username, event_rx, gui_config).await
} }
async fn get_status( async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
client: &reqwest::Client,
_address: &str,
) -> color_eyre::Result<ServerStatus> {
Ok(client Ok(client
.get(absolute_url("status")?) .get(absolute_url("status")?)
.send() .send()
@@ -161,7 +179,7 @@ pub struct WebAudioSystem {
processors: AudioProcessorSender, processors: AudioProcessorSender,
} }
async fn attach_worklet(audio_context: &AudioContext, worklet_url: &str) -> Result<(), Error> { async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
// Create worklets to process mic and speaker audio // Create worklets to process mic and speaker audio
// Speaker audio processing worklet only required on // Speaker audio processing worklet only required on
// browsers that don't support MediaStreamTrackGenerator // browsers that don't support MediaStreamTrackGenerator
@@ -174,11 +192,12 @@ async fn attach_worklet(audio_context: &AudioContext, worklet_url: &str) -> Resu
) )
.ey()?; .ey()?;
info!("loading mic worklet from {worklet_url:?}"); let module = asset!("assets/rust_audio_worklet.js").to_string();
info!("loading mic worklet from {module:?}");
audio_context audio_context
.audio_worklet() .audio_worklet()
.ey()? .ey()?
.add_module_with_options(worklet_url, &options) .add_module_with_options(&module, &options)
.ey()? .ey()?
.into_future() .into_future()
.await .await
@@ -193,11 +212,7 @@ impl super::AudioSystemInterface for WebAudioSystem {
// Create MediaStreams to playback decoded audio // Create MediaStreams to playback decoded audio
// The audio context is used to reproduce audio. // The audio context is used to reproduce audio.
let webctx = configure_audio_context(); let webctx = configure_audio_context();
attach_worklet( attach_worklet(&webctx).await?;
&webctx,
&asset!("/assets/rust_audio_worklet.js").to_string(),
)
.await?;
let processors = AudioProcessorSender::default(); let processors = AudioProcessorSender::default();
@@ -388,7 +403,7 @@ async fn run_encoder_worklet(
audio_encoder.configure(&encoder_config); audio_encoder.configure(&encoder_config);
info!("created audio encoder"); info!("created audio encoder");
let mut current_processor = AudioProcessor::new(false); let mut current_processor = AudioProcessor::new_plain();
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| { let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
if let Some(new_processor) = processors.take() { if let Some(new_processor) = processors.take() {
current_processor = new_processor; current_processor = new_processor;
@@ -441,8 +456,7 @@ pub async fn network_connect(
address: String, address: String,
username: String, username: String,
event_rx: &mut UnboundedReceiver<Command>, event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides, gui_config: &ClientConfig,
state: SharedState,
) -> Result<(), Error> { ) -> Result<(), Error> {
info!("connecting"); info!("connecting");
@@ -455,7 +469,7 @@ pub async fn network_connect(
) )
.ey()?; .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()); let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?; web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?;
} }
@@ -501,9 +515,7 @@ pub async fn network_connect(
let writer = let writer =
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec); asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
let (outgoing_send, outgoing_recv) = futures_channel::mpsc::unbounded(); crate::network_loop(username, event_rx, reader, writer).await
spawn(crate::sender_loop(outgoing_recv, writer));
crate::network_loop(username, state, event_rx, outgoing_send, reader).await
} }
pub fn absolute_url(path: &str) -> Result<Url, Error> { pub fn absolute_url(path: &str) -> Result<Url, Error> {
@@ -511,64 +523,3 @@ pub fn absolute_url(path: &str) -> Result<Url, Error> {
let location = window.location(); let location = window.location();
Ok(Url::parse(&location.href().ey()?)?.join(path)?) Ok(Url::parse(&location.href().ey()?)?.join(path)?)
} }
#[derive(Clone, PartialEq)]
pub struct WebConfigSystem {}
impl super::ConfigSystemInterface for WebConfigSystem {
fn new() -> Result<Self, Error> {
return Ok(WebConfigSystem {});
}
fn config_get<T>(&self, key: &str) -> Option<T>
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::<T>(&raw) {
return Some(parsed);
}
}
// Fallback to default if deserialization fails or key missing
let default_value = config_get_default(key)?;
serde_json::from_value::<T>(default_value).ok()
}
fn config_set<T>(&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<serde_json::Value> {
let default_config = platform_default_config();
default_config
.get(key)
.cloned()
.or(super::global_default_config().get(key).cloned())
}
fn platform_default_config() -> HashMap<String, serde_json::Value> {
serde_json::json!({})
.as_object()
.unwrap()
.clone()
.into_iter()
.collect()
}
+59 -64
View File
@@ -1,20 +1,19 @@
use crate::msghtml::process_message_html; use app::Chat;
use crate::AudioSettings; use app::Command;
use crate::Chat; use app::ConnectionState;
use crate::Command; use app::STATE;
use crate::ConnectionState;
use asynchronous_codec::FramedRead; use asynchronous_codec::FramedRead;
use asynchronous_codec::FramedWrite; use asynchronous_codec::FramedWrite;
use color_eyre::eyre::{bail, Error}; use color_eyre::eyre::{bail, Error};
use dioxus_signals::ReadableExt as _; use dioxus::prelude::*;
use dioxus_signals::WritableExt as _;
use futures::select; use futures::select;
use futures::AsyncRead; use futures::AsyncRead;
use futures::AsyncWrite; use futures::AsyncWrite;
use futures::FutureExt as _; use futures::FutureExt as _;
use futures::SinkExt as _; use futures::SinkExt as _;
use futures::StreamExt as _; use futures::StreamExt as _;
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures_channel::mpsc::UnboundedSender;
use msghtml::process_message_html;
use mumble_protocol::control::msgs; use mumble_protocol::control::msgs;
use mumble_protocol::control::ControlCodec; use mumble_protocol::control::ControlCodec;
use mumble_protocol::control::ControlPacket; use mumble_protocol::control::ControlPacket;
@@ -28,15 +27,18 @@ use std::time::Duration;
use tracing::error; use tracing::error;
use tracing::info; use tracing::info;
use crate::app::SharedState;
use crate::app::State;
use crate::effects::AudioProcessor; use crate::effects::AudioProcessor;
use crate::imp::{ use crate::imp::{
spawn, AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
Platform, PlatformInterface as _, PlatformInterface as _,
}; };
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>, state: SharedState) { pub mod app;
mod effects;
pub mod imp;
mod msghtml;
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
loop { loop {
let Some(Command::Connect { let Some(Command::Connect {
address, address,
@@ -47,43 +49,37 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>, state:
panic!("did not receive connect command") panic!("did not receive connect command")
}; };
*state.server.write_unchecked() = Default::default(); *STATE.server.write() = Default::default();
*state.status.write_unchecked() = ConnectionState::Connecting; *STATE.status.write() = ConnectionState::Connecting;
if let Err(error) = if let Err(error) =
Platform::network_connect(address, username, &mut event_rx, &config, state.clone()) Platform::network_connect(address, username, &mut event_rx, &config).await
.await
{ {
error!("could not connect {:?}", error); error!("could not connect {:?}", error);
*state.status.write_unchecked() = ConnectionState::Failed(error.to_string()); *STATE.status.write() = ConnectionState::Failed(error.to_string());
} else { } else {
*state.status.write_unchecked() = ConnectionState::Disconnected; *STATE.status.write() = ConnectionState::Disconnected;
} }
} }
} }
pub(crate) async fn sender_loop<W: AsyncWrite + Unpin + 'static>( pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin + 'static>(
mut outgoing: UnboundedReceiver<ControlPacket<Serverbound>>,
mut writer: FramedWrite<W, ControlCodec<Serverbound, Clientbound>>,
) {
while let Some(msg) = outgoing.next().await {
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
info!("sending packet {:#?}", msg);
}
if let Err(e) = writer.send(msg).await {
error!("error sending packet {:?}", e);
break;
}
}
}
pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
username: String, username: String,
state: SharedState,
event_rx: &mut UnboundedReceiver<Command>, event_rx: &mut UnboundedReceiver<Command>,
mut outgoing: UnboundedSender<ControlPacket<Serverbound>>,
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>, mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
mut writer: FramedWrite<W, ControlCodec<Serverbound, Clientbound>>,
) -> Result<(), Error> { ) -> 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 {
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
info!("sending packet {:#?}", msg);
}
if let Err(e) = writer.send(msg).await {
error!("error sending packet {:?}", e);
break;
}
}
});
// Get version packet // Get version packet
let version = match reader.next().await { let version = match reader.next().await {
@@ -98,17 +94,17 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
msg.set_version(0x000010204); msg.set_version(0x000010204);
msg.set_release(format!("{} {}", "mumbleweb2", "6.9.0")); msg.set_release(format!("{} {}", "mumbleweb2", "6.9.0"));
//msg.set_os("Chrome".to_string()); //msg.set_os("Chrome".to_string());
outgoing.send(msg.into()).await.unwrap(); send_chan.send(msg.into()).await.unwrap();
// Send authenticate packet // Send authenticate packet
let mut msg = msgs::Authenticate::new(); let mut msg = msgs::Authenticate::new();
msg.set_username(username); msg.set_username(username);
msg.set_opus(true); msg.set_opus(true);
outgoing.send(msg.into()).await.unwrap(); send_chan.send(msg.into()).await.unwrap();
// Spawn worker to send pings // Spawn worker to send pings
{ {
let mut send_chan = outgoing.clone(); let mut send_chan = send_chan.clone();
spawn(async move { spawn(async move {
loop { loop {
if let Err(_) = send_chan.send(msgs::Ping::new().into()).await { if let Err(_) = send_chan.send(msgs::Ping::new().into()).await {
@@ -121,11 +117,10 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
} }
let mut audio = AudioSystem::new().await?; let mut audio = AudioSystem::new().await?;
audio.set_processor(AudioProcessor::new(audio_settings.denoise));
{ {
let send_chan = outgoing.clone(); let send_chan = send_chan.clone();
let mut sequence_num = 0; let mut sequence_num = 0;
if let Err(err) = audio.start_recording(move |opus_frame, is_terminator| { audio.start_recording(move |opus_frame, is_terminator| {
let _ = let _ =
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio { send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
_dst: std::marker::PhantomData, _dst: std::marker::PhantomData,
@@ -136,9 +131,7 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
position_info: None, position_info: None,
}))); })));
sequence_num = sequence_num.wrapping_add(2); sequence_num = sequence_num.wrapping_add(2);
}) { });
error!("could not begin recording: {err:?}")
}
} }
// Create map of session_id -> AudioDecoder // Create map of session_id -> AudioDecoder
@@ -156,7 +149,7 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) { if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) {
info!("receiving packet {:#?}", msg); info!("receiving packet {:#?}", msg);
} }
let res = accept_packet(msg, &mut audio, &mut decoder_map, &state); let res = accept_packet(msg, &mut audio, &mut decoder_map);
if let Err(err) = res { if let Err(err) = res {
error!("error accepting packet {:?}", err) error!("error accepting packet {:?}", err)
} }
@@ -175,7 +168,7 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
match command { match command {
Some(Command::Disconnect) => break, Some(Command::Disconnect) => break,
Some(command) => { Some(command) => {
let res = accept_command(command, &mut outgoing, &mut audio, &state); let res = accept_command(command, &mut send_chan, &mut audio);
if let Err(err) = res { if let Err(err) = res {
info!("error accepting command {:?}", err) info!("error accepting command {:?}", err)
} }
@@ -185,7 +178,7 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
} }
} }
} }
let _ = outgoing.close(); let _ = send_chan.close();
Ok(()) Ok(())
} }
@@ -194,10 +187,9 @@ fn accept_command(
command: Command, command: Command,
send_chan: &mut UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>, send_chan: &mut UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
audio: &mut AudioSystem, audio: &mut AudioSystem,
state: &State,
) -> Result<(), Error> { ) -> Result<(), Error> {
use Command::*; use Command::*;
let Some(session) = state.server.read().session else { let Some(session) = STATE.server.read().session else {
bail!("no session id") bail!("no session id")
}; };
@@ -220,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 { let Some(me) = server.session else {
bail!("not signed in with a session id") bail!("not signed in with a session id")
}; };
@@ -261,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 { let Some(me) = server.session else {
bail!("not signed in with a session id") bail!("not signed in with a session id")
}; };
@@ -296,8 +288,12 @@ fn accept_command(
let _ = send_chan.unbounded_send(u.into()); let _ = send_chan.unbounded_send(u.into());
} }
Connect { .. } | Disconnect => (), Connect { .. } | Disconnect => (),
UpdateAudioSettings(AudioSettings { denoise }) => { UpdateMicEffects { denoise } => {
audio.set_processor(AudioProcessor::new(denoise)); if denoise {
audio.set_processor(AudioProcessor::new_denoising());
} else {
audio.set_processor(AudioProcessor::new_plain());
}
} }
} }
@@ -308,7 +304,6 @@ fn accept_packet(
msg: ControlPacket<mumble_protocol::Clientbound>, msg: ControlPacket<mumble_protocol::Clientbound>,
audio_context: &mut AudioSystem, audio_context: &mut AudioSystem,
player_map: &mut HashMap<u32, AudioPlayer>, player_map: &mut HashMap<u32, AudioPlayer>,
state: &State,
) -> Result<(), Error> { ) -> Result<(), Error> {
match msg { match msg {
ControlPacket::UDPTunnel(u) => { ControlPacket::UDPTunnel(u) => {
@@ -345,15 +340,15 @@ fn accept_packet(
} }
} }
ControlPacket::ChannelState(u) => { ControlPacket::ChannelState(u) => {
let mut server = state.server.write_unchecked(); let mut server = STATE.server.write();
server.channels_state.update_from_channel_state(&u); server.channels_state.update_from_channel_state(&u);
} }
ControlPacket::ChannelRemove(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); server.channels_state.update_from_channel_remove(&u);
} }
ControlPacket::UserState(u) => { ControlPacket::UserState(u) => {
let mut server = state.server.write_unchecked(); let mut server = STATE.server.write();
let server = &mut *server; let server = &mut *server;
let id = u.get_session(); let id = u.get_session();
@@ -397,7 +392,7 @@ fn accept_packet(
} }
} }
ControlPacket::UserRemove(u) => { ControlPacket::UserRemove(u) => {
let mut server = state.server.write_unchecked(); let mut server = STATE.server.write();
let id = u.get_session(); let id = u.get_session();
if let Some(state) = server.users.remove(&id) { if let Some(state) = server.users.remove(&id) {
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) { if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
@@ -406,7 +401,7 @@ fn accept_packet(
} }
} }
ControlPacket::TextMessage(u) => { ControlPacket::TextMessage(u) => {
let mut server = state.server.write_unchecked(); let mut server = STATE.server.write();
if u.has_message() { if u.has_message() {
let text = u.get_message().to_string(); let text = u.get_message().to_string();
server.chat.push(Chat { server.chat.push(Chat {
@@ -421,8 +416,8 @@ fn accept_packet(
} }
} }
ControlPacket::ServerSync(u) => { ControlPacket::ServerSync(u) => {
*state.status.write_unchecked() = ConnectionState::Connected; *STATE.status.write() = ConnectionState::Connected;
let mut server = state.server.write_unchecked(); let mut server = STATE.server.write();
if u.has_welcome_text() { if u.has_welcome_text() {
let text = u.get_welcome_text().to_string(); let text = u.get_welcome_text().to_string();
server.chat.push(Chat { server.chat.push(Chat {
+2 -736
View File
@@ -1,740 +1,6 @@
#![allow(non_snake_case)] use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
use dioxus::prelude::*;
use mumble_web2_client::{
network_entrypoint, reqwest, AudioSettings, ChannelId, Command, ConfigSystem,
ConfigSystemInterface as _, ConnectionState, Platform, PlatformInterface as _, ServerState,
SharedState, State, UserId, UserState, VERSION,
};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::collections::{HashMap, HashSet};
use std::{fmt, sync::Arc};
use Command::*;
use ConnectionState::*;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum UserIcon {
Normal,
Muted,
Deafened,
Suppressed,
None,
}
impl UserIcon {
pub fn icon(user: &UserState) -> UserIcon {
if user.deaf || user.self_deaf {
UserIcon::Deafened
} else if user.mute || user.self_mute {
UserIcon::Muted
} else if user.suppress {
UserIcon::Suppressed
} else {
UserIcon::Normal
}
}
pub fn url(self) -> Option<Asset> {
// speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/
// mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/
use UserIcon::*;
Some(match self {
Normal => asset!("assets/mic-svgrepo-com.svg"),
Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"),
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
None => return Option::None,
})
}
}
#[component]
pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
let color = match icon {
UserIcon::Normal => "var(--accent-normal)",
UserIcon::Muted => "var(--accent-muted)",
UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)",
UserIcon::None => "var(--accent-normal)",
};
rsx!(
div {
class: match isself { true => "userpil is_self", false => "userpil" },
style: "background-color: {color}",
{ icon.url().map(|url| rsx!(img { src: url })) }
"\u{00A0}{name}\u{00A0}"
}
)
}
#[component]
pub fn User(id: UserId) -> Element {
let state = use_context::<SharedState>();
let server = state.server.read();
match server.users.get(&id) {
Some(state) => rsx!(UserPill {
name: state.name.clone(),
icon: UserIcon::icon(state),
isself: server.session.unwrap() == id,
}),
None => rsx!(UserPill {
name: format!("unknown user ({id})"),
icon: UserIcon::None,
isself: false,
}),
}
}
#[component]
pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let state = use_context::<SharedState>();
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}");
};
let mut open = use_signal(|| true);
let has_children = !state.users.is_empty() || !state.children.is_empty();
rsx!(
div {
class: "channel_details",
div {
class: "channel_header",
// Arrow: only toggles open
if has_children {
span {
class: "channel_arrow",
onclick: move |evt| {
evt.stop_propagation();
evt.prevent_default();
let mut w = open.write();
*w = !*w;
},
if *open.read() { "" } else { "" }
}
} else {
span {
class: "channel_arrow channel_arrow--placeholder",
" "
}
}
// Clickable row area (everything except the arrow)
div {
class: "channel_row_click",
ondblclick: move |evt| {
evt.stop_propagation();
evt.prevent_default();
net.send(EnterChannel { channel: id, user })
},
// remove dblclick from the inner span
span {
class: "channel_title",
"{state.name}"
}
// if you add icons/badges later, put them here too
}
}
if *open.read() && has_children {
div {
class: "channel_children",
for id in state.users.iter() {
User { id: *id }
}
for child in state.children.iter() {
Channel { id: *child }
}
}
}
}
)
}
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn pick_and_send_file(net: &Coroutine<Command>) {
let state = use_context::<SharedState>();
let channels = if let Some(user) = state.server.read().this_user() {
vec![user.channel]
} else {
return;
};
let dialog = rfd::AsyncFileDialog::new().pick_file();
let sender = net.tx();
spawn(async move {
let Some(handle) = dialog.await else { return };
let name = handle.file_name();
let bytes = handle.read().await;
let mime = mumble_web2_client::mime_guess::from_path(&name).first();
let _ = sender.unbounded_send(SendFile {
bytes,
name,
mime,
channels,
});
});
}
#[cfg(not(any(feature = "desktop", feature = "web")))]
pub fn pick_and_send_file(net: &Coroutine<Command>) {}
#[component]
pub fn ChatView() -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let state = use_context::<SharedState>();
let server = state.server.read();
let mut draft = use_signal(|| "".to_string());
let mut do_send = move || {
let state = use_context::<SharedState>();
let server = state.server.read();
if let Some(user) = server.this_user() {
net.send(SendChat {
markdown: draft.write().split_off(0),
channels: vec![user.channel],
});
}
};
rsx!(
div {
class: "chat_panel",
div {
class: "chat_history",
for chat in server.chat.iter() {
div {
class: "chat_message",
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
UserPill {
name: sender.name.clone(),
icon: UserIcon::None,
isself: false,
}
}
span {
dangerous_inner_html: "{chat.dangerous_html}",
}
}
}
}
div {
class: "chat_box_wrapper",
div {
class: "chat_box",
input {
placeholder: "say something",
value: "{draft.read()}",
oninput: move |evt| draft.set(evt.value().clone()),
onkeypress: move |evt: Event<KeyboardData>| {
if evt.code() == Code::Enter && evt.modifiers().is_empty() {
do_send();
}
}
}
div {
span {
onclick: move |_| pick_and_send_file(&net),
class: "material-symbols-outlined",
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;",
"attach_file",
}
}
div {
span {
onclick: move |_| do_send(),
class: "material-symbols-outlined",
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;",
"send",
}
}
}
//button {
// onclick: move |_| do_send(),
// "Send"
//}
}
}
)
}
#[component]
pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let state = use_context::<SharedState>();
let status = &state.status;
let server = state.server.read();
let audio = state.audio.read();
let Some(&UserState {
deaf,
self_deaf,
mute,
suppress,
self_mute,
ref name,
channel,
..
}) = server.this_user()
else {
return rsx!();
};
let current_channel_name = server.channels_state.channels[&channel].name.clone();
let proxy_url = overrides
.read_unchecked()
.as_ref()
.and_then(|overrides| overrides.proxy_url.clone());
let connecting_color = "yellow";
let connected_color = "oklch(0.55 0.1184 141.35)";
let disconnected_color = "gray";
let failed_color = "red";
let connection_status = match &*status.read() {
Connecting => rsx! {
div {
class: "connection_status",
style: "color: {connecting_color};",
div {
span {
class: "material-symbols-outlined",
"signal_cellular_alt_2_bar"
}
span {
class: "status_text",
" Connecting"
}
}
}
},
Connected => rsx! {
div {
class: "connection_status",
div {
style: "color: {connected_color};",
span {
class: "material-symbols-outlined",
"signal_cellular_alt"
}
span {
class: "status_text",
" Connected"
}
}
div {
class: "channel_text",
span { "{current_channel_name}" }
}
}
},
Disconnected => rsx! {
div {
class: "connection_status",
style: "color: {disconnected_color};",
div {
span {
class: "material-symbols-outlined",
"signal_disconnected"
}
span {
class: "status_text",
" Disconnected"
}
}
}
},
Failed(_) => rsx! {
div {
class: "connection_status",
style: "color: {failed_color};",
div {
span {
class: "material-symbols-outlined",
"error"
}
span {
class: "status_text",
" Failed"
}
}
}
},
};
rsx!(
// Server control
div {
class: "button_row",
div {
{connection_status}
}
span { class: "spacer" }
button {
class: "toggle_button",
onclick: move |_| net.send(Disconnect),
span {
class: "material-symbols-outlined",
"signal_disconnected"
}
}
}
hr { style: "width: 100%;" }
// User control
div {
class: "button_row",
button {
class: "user_edit_button",
span {
class: "material-symbols-outlined",
style: "color: oklch(0.65 0.2245 28.06);",
"person_edit"
}
}
div {
class: "user_info",
div {
span { class: "user_name", "{name}" }
}
div {
span { class: "user_data", "some data" }
}
}
span { class: "spacer" }
button {
class: match audio.denoise {
true => "toggle_button is_on",
false => "toggle_button",
},
role: "switch",
aria_checked: audio.denoise,
onclick: move |_| {
let state = use_context::<SharedState>();
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::<ConfigSystem>();
user_config.config_set::<bool>("denoise", &denoise);
},
match audio.denoise {
true => rsx!(span { class: "material-symbols-outlined", "cadence"}),
false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}),
}
}
button {
class: match mute || suppress || self_mute {
true => "toggle_button is_on",
false => "toggle_button",
},
role: "switch",
aria_checked: mute || suppress || self_mute,
disabled: mute || suppress,
onclick: move |_| net.send(SetMute { mute: !self_mute }),
match mute || suppress || self_mute {
true => rsx!(span { class: "material-symbols-outlined", "mic_off"}),
false => rsx!(span { class: "material-symbols-outlined", "mic"}),
}
}
button {
class: match deaf || self_deaf {
true => "toggle_button in_on",
false => "toggle_button",
},
role: "switch",
aria_checked: deaf || self_deaf,
disabled: deaf,
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
match deaf || self_deaf {
true => rsx!(span { class: "material-symbols-outlined", "volume_off"}),
false => rsx!(span { class: "material-symbols-outlined", "volume_up"}),
}
}
}
)
}
#[component]
pub fn ServerView(overrides: Resource<ProxyOverrides>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let state = use_context::<SharedState>();
let server = state.server.read();
let Some(&UserState {
deaf,
self_deaf,
mute,
self_mute,
..
}) = server.this_user()
else {
return rsx!();
};
rsx!(
div {
class: "server_grid",
div {
class: "server_channel_box",
for (id, state) in server.channels_state.channels.iter() {
if state.parent.is_none() {
Channel { id: *id }
}
}
}
div {
class: "server_chat_box",
ChatView {}
}
div {
class: "server_control_box",
ControlView { overrides }
}
}
)
}
#[component]
pub fn LoginView(overrides: Resource<ProxyOverrides>) -> Element {
let user_config = use_context::<ConfigSystem>();
let net: Coroutine<Command> = use_coroutine_handle();
let mut address_input = use_signal(|| user_config.config_get::<String>("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 last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
use_resource(move || {
let addr = address();
async move {
let client = reqwest::Client::new();
loop {
*last_status.write_unchecked() =
Some(Platform::get_status(&client, &addr).await);
Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await;
}
}
});
let mut username = use_signal(|| {
user_config
.config_get::<String>("username")
.unwrap_or(String::new())
});
let do_connect = move |_| {
let _ = user_config.config_set::<String>("username", &username.read());
if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) {
user_config.config_set::<String>("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::<SharedState>();
let status = &state.status;
let bottom = match &*status.read() {
Disconnected => rsx! {
button {
class: "login_bttn",
onclick: do_connect.clone(),
"Connect"
}
},
Connecting => 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}"
}
}
),
Connected => unreachable!(),
};
rsx!(
div {
class: "login",
h1 {
"Mumble Web"
match VERSION {
Some(v) => rsx!(" " span { 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 {
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()),
}
}
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 server"
}
}),
}
div {
{bottom}
}
}
}
)
// 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}
// }
// )
}
#[component]
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::<bool>("denoise").unwrap_or(true),
}),
})
});
let network_state = state.clone();
use_coroutine(move |rx: UnboundedReceiver<Command>| {
network_entrypoint(rx, network_state.clone())
});
let overrides = use_resource(|| async move {
match Platform::load_proxy_overrides().await {
Ok(overrides) => overrides,
Err(_) => ProxyOverrides::default(),
}
});
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 }),
}
)
}
pub fn main() { pub fn main() {
Platform::init_logging(); Platform::init_logging();
dioxus::LaunchBuilder::new() dioxus::launch(app::app);
.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("<style>html, body { background: black; } #main { visibility: hidden; }</style>".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);
} }
+2 -1
View File
@@ -12,7 +12,7 @@ tokio-rustls = "0.26"
toml = "0.8" toml = "0.8"
tracing = { version = "^0.1.40", features = ["async-await"] } tracing = { version = "^0.1.40", features = ["async-await"] }
tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] } tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] }
mumble-web2-common = { workspace = true, features = ["networking"] } mumble-web2-common = { workspace = true }
salvo = { version = "^0.84.2", features = [ salvo = { version = "^0.84.2", features = [
"quinn", "quinn",
"eyre", "eyre",
@@ -28,3 +28,4 @@ rcgen = "^0.13.2"
hmac-sha256 = "^1.1.8" hmac-sha256 = "^1.1.8"
time = "0.3" time = "0.3"
url = { version = "2", features = ["serde"] } url = { version = "2", features = ["serde"] }
rand = "0.9.2"
+78 -16
View File
@@ -1,5 +1,6 @@
use color_eyre::eyre::{anyhow, bail, Context, Result}; use color_eyre::eyre::{anyhow, bail, Context, Result};
use mumble_web2_common::{ping_server, ProxyOverrides, ServerStatus}; use mumble_web2_common::{ClientConfig, ServerStatus};
use rand::Rng;
use salvo::conn::rustls::{Keycert, RustlsConfig}; use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::cors::{AllowOrigin, Cors}; use salvo::cors::{AllowOrigin, Cors};
use salvo::logging::Logger; use salvo::logging::Logger;
@@ -15,7 +16,7 @@ use tokio::net::TcpStream;
use tokio::pin; use tokio::pin;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier}; use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime}; 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 tokio_rustls::{rustls, TlsConnector};
use tracing::info; use tracing::info;
use tracing::info_span; use tracing::info_span;
@@ -25,6 +26,8 @@ use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use url::Url; use url::Url;
mod ping;
fn default_cert_alt_names() -> Vec<String> { fn default_cert_alt_names() -> Vec<String> {
vec!["localhost".into()] vec!["localhost".into()]
} }
@@ -74,7 +77,7 @@ async fn main() -> Result<()> {
.install_default() .install_default()
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?; .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 { proxy_url: match &server_config.proxy_url {
Some(url) => Some(url.to_string()), Some(url) => Some(url.to_string()),
None => None, None => None,
@@ -99,7 +102,7 @@ async fn main() -> Result<()> {
let cert = cert_params.self_signed(&key_pair)?; let cert = cert_params.self_signed(&key_pair)?;
let hash = hmac_sha256::Hash::hash(cert.der().as_ref()); 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()) (cert.pem().into(), key_pair.serialize_pem().into())
} }
@@ -119,11 +122,14 @@ async fn main() -> Result<()> {
}; };
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice())); 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 { let config_craft = ConfigCraft {
server_config: server_config.clone(), server_config: server_config.clone(),
overrides, client_config,
}; };
let status_craft = StatusCraft { let status_craft = StatusCraft {
@@ -133,7 +139,7 @@ async fn main() -> Result<()> {
// Server routing // Server routing
let mut router = Router::new() let mut router = Router::new()
.push(Router::with_path("/proxy").goal(config_craft.connect_proxy())) .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())) .push(Router::with_path("/status").get(status_craft.get_status()))
.hoop(Logger::new()); .hoop(Logger::new());
if let Some(gui_path) = server_config.gui_path.clone() { if let Some(gui_path) = server_config.gui_path.clone() {
@@ -176,28 +182,84 @@ pub struct StatusCraft {
impl StatusCraft { impl StatusCraft {
#[craft(handler)] #[craft(handler)]
async fn get_status(&self) -> Json<ServerStatus> { async fn get_status(&self) -> Json<ServerStatus> {
let addr = self.mumble_server_address; let mut server_status = ServerStatus::default();
match ping_server(&addr.ip().to_string(), addr.port()).await {
Ok(status) => Json(status), let ping_packet = ping::PingPacket {
id: rand::rng().random(),
};
let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => { Err(e) => {
error!("ping failed: {e:#}"); error!("Could not bind udp socket: {}", e);
Json(ServerStatus::default()) return Json(server_status);
}
};
match sock.connect(self.mumble_server_address).await {
Ok(_) => {}
Err(e) => {
error!("Could not send ping packet: {}", e);
return Json(server_status);
} }
} }
match sock.send(&<[u8; 12]>::from(ping_packet)).await {
Ok(_) => {}
Err(e) => {
error!("Could not send ping packet");
return Json(server_status);
}
}
let mut pong_buf: [u8; 24] = [0; 24];
match tokio::time::timeout(
tokio::time::Duration::from_secs(1),
sock.recv(&mut pong_buf),
)
.await
{
Ok(_) => {}
Err(e) => {
error!("Could not send ping packet");
return Json(server_status);
}
}
let pong_packet = match ping::PongPacket::try_from(pong_buf.as_slice()) {
Ok(p) => p,
Err(e) => {
error!("Could not parse pong packet: {:?}", e);
return Json(server_status);
}
};
server_status.success = true;
server_status.version = Some((
pong_packet.version & 0xFF,
(pong_packet.version >> 8) & 0xFF,
(pong_packet.version >> 16) & 0xFF,
));
server_status.users = Some(pong_packet.users);
server_status.max_users = Some(pong_packet.max_users);
server_status.bandwidth = Some(pong_packet.bandwidth);
Json(server_status)
} }
} }
#[derive(Clone)] #[derive(Clone)]
pub struct ConfigCraft { pub struct ConfigCraft {
server_config: Arc<Config>, server_config: Arc<Config>,
overrides: ProxyOverrides, client_config: ClientConfig,
} }
#[craft] #[craft]
impl ConfigCraft { impl ConfigCraft {
#[craft(handler)] #[craft(handler)]
async fn get_overrides(&self) -> Json<ProxyOverrides> { async fn get_config(&self) -> Json<ClientConfig> {
Json(self.overrides.clone()) Json(self.client_config.clone())
} }
#[craft(handler)] #[craft(handler)]
@@ -258,7 +320,7 @@ async fn connect_proxy_impl(
) -> Result<()> { ) -> Result<()> {
info!("connecting to Mumble server..."); info!("connecting to Mumble server...");
let config = ClientConfig::builder() let config = RlsClientConfig::builder()
.dangerous() .dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) .with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth(); .with_no_client_auth();
+141
View File
@@ -0,0 +1,141 @@
// This code was taken from mumble-protocol-2x (https://github.com/dblsaiko/rust-mumble-protocol)
// and originally from mumble-protocol (https://github.com/Johni0702/rust-mumble-protocol)
// These projects are licensed under MIT and Apache 2.0.
//! Ping messages and codec
//!
//! A Mumble client can send periodic UDP [PingPacket]s to servers
//! in order to query their current state and measure latency.
//! A server will usually respond with a corresponding [PongPacket] containing
//! the requested details.
//!
//! Both packets are of fixed size and can be converted to/from `u8` arrays/slices via
//! the respective `From`/`TryFrom` impls.
/// A ping packet sent to the server.
#[derive(Clone, Debug, PartialEq)]
pub struct PingPacket {
/// Opaque, client-generated id.
///
/// Will be returned by the server unmodified and can be used to correlate
/// pong replies to ping requests to e.g. calculate latency.
pub id: u64,
}
/// A pong packet sent to the client in reply to a previously received [PingPacket].
#[derive(Clone, Debug, PartialEq)]
pub struct PongPacket {
/// Opaque, client-generated id.
///
/// Should match the value in the corresponding [PingPacket].
pub id: u64,
/// Server version. E.g. `0x010300` for `1.3.0`.
pub version: u32,
/// Current amount of users connected to the server.
pub users: u32,
/// Configured limit on the amount of users which can be connected to the server.
pub max_users: u32,
/// Maximum bandwidth for server-bound speech per client in bits per second
pub bandwidth: u32,
}
/// Error during parsing of a [PingPacket].
#[derive(Clone, Debug, PartialEq)]
pub enum ParsePingError {
/// Ping packets must always be 12 bytes in size.
InvalidSize,
/// Ping packets must have an all zero header of 4 bytes.
InvalidHeader,
}
impl TryFrom<&[u8]> for PingPacket {
type Error = ParsePingError;
fn try_from(buf: &[u8]) -> Result<Self, Self::Error> {
match <[u8; 12]>::try_from(buf) {
Ok(array) => {
if array[0..4] != [0, 0, 0, 0] {
Err(ParsePingError::InvalidHeader)
} else {
Ok(Self {
id: u64::from_be_bytes(array[4..12].try_into().unwrap()),
})
}
}
Err(_) => Err(ParsePingError::InvalidSize),
}
}
}
impl From<PingPacket> for [u8; 12] {
fn from(packet: PingPacket) -> Self {
let id = packet.id.to_be_bytes();
// Is there no nicer way to do this?
[
0, 0, 0, 0, id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7],
]
}
}
/// Error during parsing of a [PongPacket].
#[derive(Clone, Debug, PartialEq)]
pub enum ParsePongError {
/// Pong packets must always be 24 bytes in size.
InvalidSize,
}
impl TryFrom<&[u8]> for PongPacket {
type Error = ParsePongError;
fn try_from(buf: &[u8]) -> Result<Self, Self::Error> {
match <[u8; 24]>::try_from(buf) {
Ok(array) => Ok(Self {
version: u32::from_be_bytes(array[0..4].try_into().unwrap()),
id: u64::from_be_bytes(array[4..12].try_into().unwrap()),
users: u32::from_be_bytes(array[12..16].try_into().unwrap()),
max_users: u32::from_be_bytes(array[16..20].try_into().unwrap()),
bandwidth: u32::from_be_bytes(array[20..24].try_into().unwrap()),
}),
Err(_) => Err(ParsePongError::InvalidSize),
}
}
}
impl From<PongPacket> for [u8; 24] {
fn from(packet: PongPacket) -> Self {
let version = packet.version.to_be_bytes();
let id = packet.id.to_be_bytes();
let users = packet.users.to_be_bytes();
let max_users = packet.max_users.to_be_bytes();
let bandwidth = packet.bandwidth.to_be_bytes();
// Is there no nicer way to do this?
[
version[0],
version[1],
version[2],
version[3],
id[0],
id[1],
id[2],
id[3],
id[4],
id[5],
id[6],
id[7],
users[0],
users[1],
users[2],
users[3],
max_users[0],
max_users[1],
max_users[2],
max_users[3],
bandwidth[0],
bandwidth[1],
bandwidth[2],
bandwidth[3],
]
}
}