6 Commits

Author SHA1 Message Date
Builder 26a08acc36 Implement mumble UDP ping protocol for server status display
Build Mumble Web 2 / windows_build (push) Successful in 2m45s
Build Mumble Web 2 / linux_build (push) Successful in 1m20s
Build Mumble Web 2 / android_build (push) Successful in 4m26s
Adds ping_server method to PlatformInterface. The desktop implementation
sends a 12-byte UDP datagram (4 zero bytes + 8-byte request ID) and
parses the 24-byte response to extract version, current users, max
users, and bandwidth. Includes a 2-second timeout.

The ServerPingInfo component uses use_resource to asynchronously ping
each server and displays user count (e.g. "3/50") on the server card.
Web and mobile platforms return an error (UDP not available in browsers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 02:20:38 +00:00
Builder b20ed1ff56 Wire LoginView to persisted servers with add/edit/delete and overrides mode
Replaces the hardcoded server list with data from the settings store.
The Add Server modal now saves entries with all fields wired to signals.
An Edit Server modal pre-populates from the existing entry and includes
a delete button. The connect button on each card initiates connection
using that server's configured address, port, and username.

In overrides mode (any_server=false), displays a single non-editable
server card with an inline username input field, allowing the user to
set their identity before connecting to the preset server.

Adds CSS for the delete button, override username row, connect button
highlight, and ping info placeholder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 02:16:00 +00:00
Builder 765446392d Add ServerEntry model and server list persistence to platform trait
Introduces a ServerEntry struct in common with name, address, port,
username, and optional password fields. Extends PlatformInterface with
load_servers/save_servers methods, implemented across all platforms
(desktop persists to JSON config, web uses localStorage, mobile/stub
are stubs).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 02:14:03 +00:00
restitux 2c22942fb3 add modal for adding server
Build Mumble Web 2 / linux_build (push) Successful in 1m20s
Build Mumble Web 2 / windows_build (push) Successful in 2m31s
Build Mumble Web 2 / android_build (push) Successful in 5m49s
2026-02-17 23:36:26 -07:00
restitux 75990ca9ce delete commented out code 2026-02-17 23:18:11 -07:00
restitux 9f6557bb92 change login screen ui 2026-02-17 22:54:37 -07:00
35 changed files with 1993 additions and 2334 deletions
-1
View File
@@ -1 +0,0 @@
target
-41
View File
@@ -42,47 +42,6 @@ jobs:
path: target/release/mumble-web2-proxy
retention-days: 5
macos_build:
runs-on: macos
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Restore Rust cache
uses: actions/cache/restore@v4
with:
path: |
~/.cargo
./target
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-
- name: Install cargo binstall
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
- name: Install dioxus-cli
run: cargo binstall dioxus-cli --version 0.7.3 --no-confirm
- name: Build dioxus project
run: dx bundle --platform macos --release -p mumble-web2-gui
- name: Save Rust cache
if: always()
uses: actions/cache/save@v4
with:
path: |
~/.cargo
./target
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- name: Upload mumble-web2-gui Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-gui-macos-arm64
path: gui/dist
retention-days: 5
windows_build:
runs-on: windows
steps:
+1 -1
View File
@@ -6,4 +6,4 @@ server_hash.txt
proxy/bundle
/config.toml
proxy/config.toml
*_onnx.tar.gz
gui/assets/*_onnx.tar.gz
Generated
+14 -26
View File
@@ -1587,9 +1587,9 @@ dependencies = [
[[package]]
name = "dioxus-core-types"
version = "0.7.4"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b99d7d199aad72431b549759550002e7d72c8a257eba500dca9fbdb2122de103"
checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f"
[[package]]
name = "dioxus-desktop"
@@ -4220,7 +4220,14 @@ dependencies = [
]
[[package]]
name = "mumble-web2-client"
name = "mumble-web2-common"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "mumble-web2-gui"
version = "0.1.0"
dependencies = [
"android-permissions",
@@ -4234,8 +4241,9 @@ dependencies = [
"crossbeam-queue",
"dasp_ring_buffer",
"deep_filter",
"dioxus",
"dioxus-asset-resolver",
"dioxus-signals",
"dioxus-web",
"etcetera",
"futures",
"futures-channel",
@@ -4244,7 +4252,6 @@ dependencies = [
"jni",
"js-sys",
"lol_html 2.7.0",
"manganis",
"markdown",
"merge-io",
"mime_guess",
@@ -4256,6 +4263,7 @@ dependencies = [
"opus",
"ordermap",
"reqwest",
"rfd 0.16.0",
"serde",
"serde-wasm-bindgen",
"serde_json",
@@ -4271,27 +4279,6 @@ dependencies = [
"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]]
name = "mumble-web2-proxy"
version = "0.1.0"
@@ -4300,6 +4287,7 @@ dependencies = [
"hmac-sha256",
"mumble-web2-common",
"once_cell",
"rand 0.9.2",
"rcgen",
"rustls",
"salvo",
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["client", "common", "gui", "proxy"]
members = ["common", "gui", "proxy"]
[workspace.dependencies]
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",
]
-221
View File
@@ -1,221 +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, Clone)]
pub enum ConnectTarget {
Direct { host: String, port: u16 },
Proxy(String),
}
#[derive(Debug)]
pub enum Command {
Connect {
target: ConnectTarget,
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>;
-65
View File
@@ -1,65 +0,0 @@
use crate::app::{Command, ConnectTarget, 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(
target: ConnectTarget,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
super::connect::network_connect(target, username, event_rx, overrides, state).await
}
async fn get_status(
_client: &reqwest::Client,
address: &str,
) -> color_eyre::Result<ServerStatus> {
let (host, port) = match address.rsplit_once(':') {
Some((h, p)) => (h, p.parse().unwrap_or(64738)),
None => (address, 64738),
};
mumble_web2_common::ping_server(host, port).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
}
}
-83
View File
@@ -1,83 +0,0 @@
use crate::app::{Command, ConnectTarget, 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(
target: ConnectTarget,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
super::connect::network_connect(target, username, event_rx, overrides, state).await
}
async fn get_status(
_client: &reqwest::Client,
address: &str,
) -> color_eyre::Result<ServerStatus> {
let (host, port) = match address.rsplit_once(':') {
Some((h, p)) => (h, p.parse().unwrap_or(64738)),
None => (address, 64738),
};
mumble_web2_common::ping_server(host, port).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();
}
}
-113
View File
@@ -1,113 +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)?;
serde_json::from_value::<T>(default_value).ok()
}
}
}
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"
edition = "2021"
[features]
networking = ["dep:tokio", "dep:color-eyre"]
[dependencies]
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};
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProxyOverrides {
pub struct ClientConfig {
pub proxy_url: Option<String>,
pub cert_hash: Option<Vec<u8>>,
pub any_server: bool,
@@ -26,61 +26,3 @@ pub struct ServerEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
}
/// 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 {
tls internal
tls internal
# Proxy /config path to mumble-web2-proxy
reverse_proxy /overrides http://127.0.0.1:4400
# Proxy /config path to mumble-web2-proxy
reverse_proxy /config http://127.0.0.1:4400
# Proxy /status path to mumble-web2-proxy
reverse_proxy /status http://127.0.0.1:4400
# Proxy /status path to mumble-web2-proxy
reverse_proxy /status http://127.0.0.1:4400
# Proxy root path to dx-serve
reverse_proxy http://127.0.0.1:8080
# Proxy root path to dx-serve
reverse_proxy http://127.0.0.1:8080
}
-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:
# - ..:/app
# environment:
# - MUMBLE_WEB2_PROXY_OVERRIDES_URL=https://localhost:64444/overrides
# - MUMBLE_WEB2_GUI_CONFIG_URL=https://localhost:64444/config
# stdin_open: true
# tty: true
# command: >
+129 -6
View File
@@ -4,11 +4,103 @@ version = "0.1.0"
edition = "2021"
[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-web = { version = "0.7.2", optional = true }
mumble-web2-client = { version = "0.1.0", path = "../client" }
mumble-web2-common = { version = "0.1.0", path = "../common" }
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"
# 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]
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]
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",
]
desktop = [
"dioxus/desktop",
"mumble-web2-client/desktop",
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
"rfd/xdg-portal",
"etcetera",
]
mobile = [
"dioxus/mobile",
"mumble-web2-client/mobile"
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
]
+2 -19
View File
@@ -16,7 +16,6 @@ body {
}
#main {
visibility: visible;
height: 100vh;
display: flex;
flex-direction: column;
@@ -582,7 +581,8 @@ a:visited {
pointer-events: auto;
/* Make this solid or nearly solid instead of see-through */
background: #141414;
/* Old: background: rgba(255, 255, 255, 0.05); */
background: #141414; /* or #151822, or rgb(15, 15, 20) */
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -643,23 +643,6 @@ a:visited {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
}
.modal-field input:user-invalid,
.modal-field--strict input:invalid {
border-color: rgba(255, 90, 90, 0.85);
box-shadow: 0 0 0 1px rgba(255, 90, 90, 0.45);
}
.modal-field__error {
display: none;
font-size: 0.75rem;
color: #ff8888;
}
.modal-field:has(input:user-invalid) .modal-field__error,
.modal-field--strict:has(input:invalid) .modal-field__error {
display: block;
}
/* Actions row */
.modal-actions {
View File
+1182
View File
File diff suppressed because it is too large Load Diff
+16 -6
View File
@@ -1,8 +1,8 @@
use crossbeam::atomic::AtomicCell;
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
use df::tract::{DfParams, DfTract, RuntimeParams};
use dioxus::prelude::{asset, manganis, Asset};
use dioxus_asset_resolver::read_asset_bytes;
use manganis::{asset, Asset};
use std::cell::RefCell;
use std::sync::Arc;
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_task = cell.clone();
*state = DenoisingModelState::Downloading(cell);
let model = DF_MODEL.to_string();
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,
Err(e) => {
error!("could not read denoising model from \"{model}\": {e:?}");
error!("could not read denoising model from \"{DF_MODEL}\": {e:?}");
return;
}
};
@@ -97,9 +96,20 @@ pub struct AudioProcessor {
}
impl AudioProcessor {
pub fn new(denoise: bool) -> Self {
pub fn new_plain() -> Self {
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(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
@@ -1,6 +1,6 @@
use crate::app::{Command, ConnectTarget, SharedState};
use crate::app::Command;
use color_eyre::eyre::{bail, Error};
use futures_channel::mpsc::UnboundedReceiver;
use dioxus::hooks::UnboundedReceiver;
use mumble_protocol::control::ClientControlCodec;
use std::net::ToSocketAddrs;
use std::sync::Arc;
@@ -8,13 +8,13 @@ use tokio::net::TcpStream;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::ClientConfig;
use tokio_rustls::rustls::ClientConfig as RlsClientConfig;
use tokio_rustls::rustls::DigitallySignedStruct;
use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{info, instrument};
use mumble_web2_common::ProxyOverrides;
use mumble_web2_common::{ClientConfig, ServerStatus};
#[derive(Debug)]
struct NoCertificateVerification;
@@ -70,36 +70,29 @@ impl ServerCertVerifier for NoCertificateVerification {
#[instrument]
pub async fn network_connect(
target: ConnectTarget,
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
gui_config: &ClientConfig,
) -> Result<(), Error> {
info!("connecting");
let (host, port) = match target {
ConnectTarget::Direct { host, port } => (host, port),
ConnectTarget::Proxy(_) => {
bail!("desktop/mobile platform requires a direct host:port, not a proxy URL")
}
};
let config = ClientConfig::builder()
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let addr = (&*host, port)
let addr = format!("{}:{}", address, 64738)
.to_socket_addrs()?
.next()
.unwrap();
let server_tcp = TcpStream::connect(addr).await?;
let server_stream = connector
.connect(host.try_into()?, server_tcp)
//.connect("127.0.0.1".try_into()?, server_tcp)
.connect(address.try_into()?, server_tcp)
.await?;
let (read_server, write_server) = tokio::io::split(server_stream);
@@ -109,9 +102,11 @@ pub async fn network_connect(
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
let (outgoing_send, outgoing_recv) = futures_channel::mpsc::unbounded();
spawn(crate::sender_loop(outgoing_recv, writer));
crate::network_loop(username, state, event_rx, outgoing_send, reader).await
crate::network_loop(username, event_rx, reader, writer).await
}
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
bail!("status not supported on desktop yet")
}
#[allow(unused)]
+182
View File
@@ -0,0 +1,182 @@
use crate::app::Command;
use color_eyre::eyre::{bail, Error};
use dioxus::hooks::UnboundedReceiver;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use mumble_web2_common::{ClientConfig, ServerEntry, 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()
}
fn load_servers() -> Vec<ServerEntry> {
let config = load_config_map();
config
.get("servers")
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default()
}
fn save_servers(servers: &[ServerEntry]) {
let mut config = load_config_map();
if let Ok(json) = serde_json::to_string(servers) {
config.insert("servers".to_string(), json);
let _ = save_config_map(&config);
}
}
async fn network_connect(
address: String,
username: String,
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
}
async fn ping_server(address: &str, port: u16) -> color_eyre::Result<ServerStatus> {
mumble_udp_ping(address, port).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: "xyz".to_string(),
author: "ohea".to_string(),
app_name: "Mumble Web2".to_string(),
})
.expect("failed to choose app strategy");
strategy.config_dir().join("config.json")
}
fn load_config_map() -> HashMap<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(())
}
/// Mumble UDP ping protocol.
///
/// Send a 12-byte packet: 4 zero bytes + 8-byte identifier.
/// Receive a 24-byte response: 4 bytes version (1 byte each: major.minor.patch + padding)
/// + 8 bytes identifier echo + 4 bytes current_users + 4 bytes max_users + 4 bytes bandwidth.
async fn mumble_udp_ping(address: &str, port: u16) -> color_eyre::Result<ServerStatus> {
use std::net::ToSocketAddrs;
use tokio::net::UdpSocket;
let dest = format!("{}:{}", address, port)
.to_socket_addrs()?
.next()
.ok_or_else(|| color_eyre::eyre::eyre!("could not resolve address"))?;
let bind_addr = if dest.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" };
let socket = UdpSocket::bind(bind_addr).await?;
socket.connect(dest).await?;
// Build ping packet: 4 zero bytes + 8-byte request ID
let request_id: u64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let mut buf = [0u8; 12];
buf[4..12].copy_from_slice(&request_id.to_be_bytes());
socket.send(&buf).await?;
let mut response = [0u8; 24];
let timeout = tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut response)).await;
match timeout {
Ok(Ok(len)) if len >= 24 => {
let version_major = response[0] as u32;
let version_minor = response[1] as u32;
let version_patch = response[2] as u32;
let users = u32::from_be_bytes([response[12], response[13], response[14], response[15]]);
let max_users = u32::from_be_bytes([response[16], response[17], response[18], response[19]]);
let bandwidth = u32::from_be_bytes([response[20], response[21], response[22], response[23]]);
Ok(ServerStatus {
success: true,
version: Some((version_major, version_minor, version_patch)),
users: Some(users),
max_users: Some(max_users),
bandwidth: Some(bandwidth),
})
}
Ok(Ok(_)) => bail!("ping response too short"),
Ok(Err(e)) => Err(e.into()),
Err(_) => bail!("ping timed out"),
}
}
@@ -1,7 +1,7 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
use std::future::Future;
use std::time::Duration;
@@ -31,10 +31,16 @@ impl super::PlatformInterface for MobilePlatform {
None
}
fn set_default_server(server: &str) -> Option<()> {
fn set_default_server(_server: &str) -> Option<()> {
None
}
fn load_servers() -> Vec<ServerEntry> {
Vec::new()
}
fn save_servers(_servers: &[ServerEntry]) {}
async fn network_connect(
address: String,
username: String,
@@ -48,6 +54,10 @@ impl super::PlatformInterface for MobilePlatform {
super::connect::get_status(client).await
}
async fn ping_server(_address: &str, _port: u16) -> color_eyre::Result<ServerStatus> {
color_eyre::eyre::bail!("ping not supported on mobile yet")
}
fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
+33 -47
View File
@@ -4,12 +4,10 @@
//! The traits make the platform boundary explicit and provide compile-time verification.
#![allow(async_fn_in_trait)]
use crate::app::{Command, ConnectTarget, SharedState};
use crate::effects::AudioProcessor;
use crate::{app::Command, effects::AudioProcessor};
use color_eyre::eyre::Error;
use futures_channel::mpsc::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::collections::HashMap;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
use std::future::Future;
use std::time::Duration;
@@ -52,24 +50,11 @@ pub trait AudioPlayerInterface {
fn play_opus(&mut self, payload: &[u8]);
}
pub trait ConfigSystemInterface: Sized + Clone {
fn new() -> Result<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
/// platform-specific functionality into a single interface, providing compile-time
/// verification that all platforms implement the required functionality.
pub trait PlatformInterface {
type AudioSystem: AudioSystemInterface;
type ConfigSystem: ConfigSystemInterface;
/// Initialize logging for the platform.
fn init_logging();
@@ -79,25 +64,43 @@ pub trait PlatformInterface {
/// Establish a connection to the Mumble server and run the network loop.
fn network_connect(
target: ConnectTarget,
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
proxy_overrides: &ProxyOverrides,
state: SharedState,
gui_config: &ClientConfig,
) -> impl Future<Output = Result<(), Error>>;
/// Get server status (user count, version, etc.) for the given address.
///
/// 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.
/// Get server status (user count, version, etc.) via the web proxy status endpoint.
fn get_status(
client: &reqwest::Client,
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
/// Ping a mumble server via UDP to get version, user count, etc.
fn ping_server(
address: &str,
port: u16,
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
/// 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<()>;
/// Load the saved server list.
fn load_servers() -> Vec<ServerEntry>;
/// Save the server list.
fn save_servers(servers: &[ServerEntry]);
/// Async sleep for the given duration.
fn sleep(duration: Duration) -> impl Future<Output = ()>;
@@ -107,21 +110,15 @@ pub trait PlatformInterface {
// Platform Modules
// ============================================================================
mod stub;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod connect;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_config;
#[cfg(feature = "desktop")]
mod desktop;
#[cfg(feature = "mobile")]
mod mobile;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
mod stub;
#[cfg(feature = "web")]
mod web;
@@ -148,8 +145,6 @@ pub type Platform = stub::StubPlatform;
pub type AudioSystem = <Platform as PlatformInterface>::AudioSystem;
pub type AudioPlayer = <AudioSystem as AudioSystemInterface>::AudioPlayer;
pub type ConfigSystem = <Platform as PlatformInterface>::ConfigSystem;
// ========================
// Platform Async Runtime
// ========================
@@ -181,12 +176,3 @@ const _: () = {
let _ = assert_platform::<mobile::MobilePlatform>;
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 =
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 processors = self.processors.clone();
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
+37 -35
View File
@@ -1,19 +1,15 @@
/// Stub implementation of the platform interface, so that we can
/// `cargo check` without any --feature flags.
use crate::{
app::{ConnectTarget, SharedState},
effects::AudioProcessor,
};
use crate::effects::AudioProcessor;
use color_eyre::eyre::Error;
use futures_channel::mpsc::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
use std::future::Future;
pub struct StubPlatform;
impl super::PlatformInterface for StubPlatform {
type AudioSystem = StubAudioSystem;
type ConfigSystem = StubConfigSystem;
fn init_logging() {
panic!("stubbed platform")
@@ -24,26 +20,55 @@ impl super::PlatformInterface for StubPlatform {
}
fn network_connect(
_target: ConnectTarget,
_address: String,
_username: String,
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
_overrides: &ProxyOverrides,
_state: SharedState,
_gui_config: &ClientConfig,
) -> impl Future<Output = Result<(), Error>> {
async { panic!("stubbed platform") }
}
fn get_status(
_client: &reqwest::Client,
_address: &str,
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
async { panic!("stubbed platform") }
}
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>> {
fn ping_server(
_address: &str,
_port: u16,
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
async { panic!("stubbed platform") }
}
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>> {
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 load_servers() -> Vec<ServerEntry> {
panic!("stubbed platform")
}
fn save_servers(_servers: &[ServerEntry]) {
panic!("stubbed platform")
}
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
async { panic!("stubbed platform") }
}
@@ -82,29 +107,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)]
pub struct SpawnHandle;
+74 -106
View File
@@ -1,15 +1,13 @@
use crate::app::{Command, ConnectTarget, SharedState};
use crate::app::Command;
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{bail, eyre, Error};
use crossbeam::atomic::AtomicCell;
use futures_channel::mpsc::UnboundedReceiver;
use dioxus::prelude::*;
use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array;
use manganis::asset;
use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use mumble_web2_common::{ClientConfig, ServerEntry, ServerStatus};
use reqwest::Url;
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
@@ -64,7 +62,6 @@ pub struct WebPlatform;
impl super::PlatformInterface for WebPlatform {
type AudioSystem = WebAudioSystem;
type ConfigSystem = WebConfigSystem;
fn init_logging() {
// copied from tracing_web example usage
@@ -92,41 +89,74 @@ impl super::PlatformInterface for WebPlatform {
// No-op on web
}
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
let overrides = match option_env!("MUMBLE_WEB2_PROXY_OVERRIDES_URL") {
async fn load_config() -> color_eyre::Result<ClientConfig> {
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
Some(url) => Url::parse(url)?,
None => absolute_url("overrides")?,
None => absolute_url("config")?,
};
info!("loading config from {}", overrides);
info!("loading config from {}", config_url);
let config = reqwest::get(overrides)
let config = reqwest::get(config_url)
.await?
.json::<ProxyOverrides>()
.json::<ClientConfig>()
.await?;
Ok(config)
}
async fn network_connect(
target: ConnectTarget,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
let url = match target {
ConnectTarget::Proxy(url) => url,
ConnectTarget::Direct { .. } => {
bail!("web platform requires a proxy URL, not a direct host:port")
}
};
network_connect(url, username, event_rx, overrides, state).await
fn load_username() -> Option<String> {
web_sys::window()
.unwrap()
.local_storage()
.ok()??
.get_item("username")
.ok()?
}
async fn get_status(
client: &reqwest::Client,
_address: &str,
) -> color_eyre::Result<ServerStatus> {
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
}
fn load_servers() -> Vec<ServerEntry> {
web_sys::window()
.and_then(|w| w.local_storage().ok()?)
.and_then(|s| s.get_item("servers").ok()?)
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default()
}
fn save_servers(servers: &[ServerEntry]) {
if let Ok(json) = serde_json::to_string(servers) {
if let Some(storage) = web_sys::window()
.and_then(|w| w.local_storage().ok()?)
{
let _ = storage.set_item("servers", &json);
}
}
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
network_connect(address, username, event_rx, gui_config).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(absolute_url("status")?)
.send()
@@ -135,6 +165,11 @@ impl super::PlatformInterface for WebPlatform {
.await?)
}
async fn ping_server(_address: &str, _port: u16) -> color_eyre::Result<ServerStatus> {
// UDP ping not available in browsers; use get_status via HTTP proxy instead
color_eyre::eyre::bail!("UDP ping not supported on web platform")
}
async fn sleep(duration: Duration) {
TimeoutFuture::new(duration.as_millis() as u32).await;
}
@@ -167,7 +202,7 @@ pub struct WebAudioSystem {
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
// Speaker audio processing worklet only required on
// browsers that don't support MediaStreamTrackGenerator
@@ -180,11 +215,12 @@ async fn attach_worklet(audio_context: &AudioContext, worklet_url: &str) -> Resu
)
.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_worklet()
.ey()?
.add_module_with_options(worklet_url, &options)
.add_module_with_options(&module, &options)
.ey()?
.into_future()
.await
@@ -199,11 +235,7 @@ impl super::AudioSystemInterface for WebAudioSystem {
// Create MediaStreams to playback decoded audio
// The audio context is used to reproduce audio.
let webctx = configure_audio_context();
attach_worklet(
&webctx,
&asset!("/assets/rust_audio_worklet.js").to_string(),
)
.await?;
attach_worklet(&webctx).await?;
let processors = AudioProcessorSender::default();
@@ -394,7 +426,7 @@ async fn run_encoder_worklet(
audio_encoder.configure(&encoder_config);
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| {
if let Some(new_processor) = processors.take() {
current_processor = new_processor;
@@ -447,8 +479,7 @@ pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
gui_config: &ClientConfig,
) -> Result<(), Error> {
info!("connecting");
@@ -461,7 +492,7 @@ pub async fn network_connect(
)
.ey()?;
if let Some(server_hash) = &overrides.cert_hash {
if let Some(server_hash) = &gui_config.cert_hash {
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?;
}
@@ -507,9 +538,7 @@ pub async fn network_connect(
let writer =
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
let (outgoing_send, outgoing_recv) = futures_channel::mpsc::unbounded();
spawn(crate::sender_loop(outgoing_recv, writer));
crate::network_loop(username, state, event_rx, outgoing_send, reader).await
crate::network_loop(username, event_rx, reader, writer).await
}
pub fn absolute_url(path: &str) -> Result<Url, Error> {
@@ -517,64 +546,3 @@ pub fn absolute_url(path: &str) -> Result<Url, Error> {
let location = window.location();
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
}
#[derive(Clone, PartialEq)]
pub struct WebConfigSystem {}
impl super::ConfigSystemInterface for WebConfigSystem {
fn new() -> Result<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()
}
+60 -65
View File
@@ -1,20 +1,19 @@
use crate::msghtml::process_message_html;
use crate::AudioSettings;
use crate::Chat;
use crate::Command;
use crate::ConnectionState;
use app::Chat;
use app::Command;
use app::ConnectionState;
use app::STATE;
use asynchronous_codec::FramedRead;
use asynchronous_codec::FramedWrite;
use color_eyre::eyre::{bail, Error};
use dioxus_signals::ReadableExt as _;
use dioxus_signals::WritableExt as _;
use dioxus::prelude::*;
use futures::select;
use futures::AsyncRead;
use futures::AsyncWrite;
use futures::FutureExt as _;
use futures::SinkExt 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::ControlCodec;
use mumble_protocol::control::ControlPacket;
@@ -28,18 +27,21 @@ use std::time::Duration;
use tracing::error;
use tracing::info;
use crate::app::SharedState;
use crate::app::State;
use crate::effects::AudioProcessor;
use crate::imp::{
spawn, AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _,
Platform, PlatformInterface as _,
AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
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 {
let Some(Command::Connect {
target,
address,
username,
config,
}) = event_rx.next().await
@@ -47,43 +49,37 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>, state:
panic!("did not receive connect command")
};
*state.server.write_unchecked() = Default::default();
*state.status.write_unchecked() = ConnectionState::Connecting;
*STATE.server.write() = Default::default();
*STATE.status.write() = ConnectionState::Connecting;
if let Err(error) =
Platform::network_connect(target, username, &mut event_rx, &config, state.clone())
.await
Platform::network_connect(address, username, &mut event_rx, &config).await
{
error!("could not connect {:?}", error);
*state.status.write_unchecked() = ConnectionState::Failed(error.to_string());
*STATE.status.write() = ConnectionState::Failed(error.to_string());
} else {
*state.status.write_unchecked() = ConnectionState::Disconnected;
*STATE.status.write() = ConnectionState::Disconnected;
}
}
}
pub(crate) async fn sender_loop<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>(
pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin + 'static>(
username: String,
state: SharedState,
event_rx: &mut UnboundedReceiver<Command>,
mut outgoing: UnboundedSender<ControlPacket<Serverbound>>,
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
mut writer: FramedWrite<W, ControlCodec<Serverbound, Clientbound>>,
) -> 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
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_release(format!("{} {}", "mumbleweb2", "6.9.0"));
//msg.set_os("Chrome".to_string());
outgoing.send(msg.into()).await.unwrap();
send_chan.send(msg.into()).await.unwrap();
// Send authenticate packet
let mut msg = msgs::Authenticate::new();
msg.set_username(username);
msg.set_opus(true);
outgoing.send(msg.into()).await.unwrap();
send_chan.send(msg.into()).await.unwrap();
// Spawn worker to send pings
{
let mut send_chan = outgoing.clone();
let mut send_chan = send_chan.clone();
spawn(async move {
loop {
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?;
audio.set_processor(AudioProcessor::new(audio_settings.denoise));
{
let send_chan = outgoing.clone();
let send_chan = send_chan.clone();
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 _ =
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
_dst: std::marker::PhantomData,
@@ -136,9 +131,7 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
position_info: None,
})));
sequence_num = sequence_num.wrapping_add(2);
}) {
error!("could not begin recording: {err:?}")
}
});
}
// 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(_)) {
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 {
error!("error accepting packet {:?}", err)
}
@@ -175,7 +168,7 @@ pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
match command {
Some(Command::Disconnect) => break,
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 {
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(())
}
@@ -194,10 +187,9 @@ fn accept_command(
command: Command,
send_chan: &mut UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
audio: &mut AudioSystem,
state: &State,
) -> Result<(), Error> {
use Command::*;
let Some(session) = state.server.read().session else {
let Some(session) = STATE.server.read().session else {
bail!("no session id")
};
@@ -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 {
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 {
bail!("not signed in with a session id")
};
@@ -296,8 +288,12 @@ fn accept_command(
let _ = send_chan.unbounded_send(u.into());
}
Connect { .. } | Disconnect => (),
UpdateAudioSettings(AudioSettings { denoise }) => {
audio.set_processor(AudioProcessor::new(denoise));
UpdateMicEffects { 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>,
audio_context: &mut AudioSystem,
player_map: &mut HashMap<u32, AudioPlayer>,
state: &State,
) -> Result<(), Error> {
match msg {
ControlPacket::UDPTunnel(u) => {
@@ -345,15 +340,15 @@ fn accept_packet(
}
}
ControlPacket::ChannelState(u) => {
let mut server = state.server.write_unchecked();
let mut server = STATE.server.write();
server.channels_state.update_from_channel_state(&u);
}
ControlPacket::ChannelRemove(u) => {
let mut server = state.server.write_unchecked();
let mut server = STATE.server.write();
server.channels_state.update_from_channel_remove(&u);
}
ControlPacket::UserState(u) => {
let mut server = state.server.write_unchecked();
let mut server = STATE.server.write();
let server = &mut *server;
let id = u.get_session();
@@ -397,7 +392,7 @@ fn accept_packet(
}
}
ControlPacket::UserRemove(u) => {
let mut server = state.server.write_unchecked();
let mut server = STATE.server.write();
let id = u.get_session();
if let Some(state) = server.users.remove(&id) {
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
@@ -406,7 +401,7 @@ fn accept_packet(
}
}
ControlPacket::TextMessage(u) => {
let mut server = state.server.write_unchecked();
let mut server = STATE.server.write();
if u.has_message() {
let text = u.get_message().to_string();
server.chat.push(Chat {
@@ -421,8 +416,8 @@ fn accept_packet(
}
}
ControlPacket::ServerSync(u) => {
*state.status.write_unchecked() = ConnectionState::Connected;
let mut server = state.server.write_unchecked();
*STATE.status.write() = ConnectionState::Connected;
let mut server = STATE.server.write();
if u.has_welcome_text() {
let text = u.get_welcome_text().to_string();
server.chat.push(Chat {
+2 -1206
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -12,7 +12,7 @@ tokio-rustls = "0.26"
toml = "0.8"
tracing = { version = "^0.1.40", features = ["async-await"] }
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 = [
"quinn",
"eyre",
@@ -28,3 +28,4 @@ rcgen = "^0.13.2"
hmac-sha256 = "^1.1.8"
time = "0.3"
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 mumble_web2_common::{ping_server, ProxyOverrides, ServerStatus};
use mumble_web2_common::{ClientConfig, ServerStatus};
use rand::Rng;
use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::cors::{AllowOrigin, Cors};
use salvo::logging::Logger;
@@ -15,7 +16,7 @@ use tokio::net::TcpStream;
use tokio::pin;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct};
use tokio_rustls::rustls::{ClientConfig as RlsClientConfig, DigitallySignedStruct};
use tokio_rustls::{rustls, TlsConnector};
use tracing::info;
use tracing::info_span;
@@ -25,6 +26,8 @@ use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::EnvFilter;
use url::Url;
mod ping;
fn default_cert_alt_names() -> Vec<String> {
vec!["localhost".into()]
}
@@ -74,7 +77,7 @@ async fn main() -> Result<()> {
.install_default()
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?;
let mut overrides = ProxyOverrides {
let mut client_config = ClientConfig {
proxy_url: match &server_config.proxy_url {
Some(url) => Some(url.to_string()),
None => None,
@@ -99,7 +102,7 @@ async fn main() -> Result<()> {
let cert = cert_params.self_signed(&key_pair)?;
let hash = hmac_sha256::Hash::hash(cert.der().as_ref());
overrides.cert_hash = Some(hash.into());
client_config.cert_hash = Some(hash.into());
(cert.pem().into(), key_pair.serialize_pem().into())
}
@@ -119,11 +122,14 @@ async fn main() -> Result<()> {
};
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
info!("proxy overrides:\n{}", toml::to_string_pretty(&overrides)?);
info!(
"client config:\n{}",
toml::to_string_pretty(&client_config)?
);
let config_craft = ConfigCraft {
server_config: server_config.clone(),
overrides,
client_config,
};
let status_craft = StatusCraft {
@@ -133,7 +139,7 @@ async fn main() -> Result<()> {
// Server routing
let mut router = Router::new()
.push(Router::with_path("/proxy").goal(config_craft.connect_proxy()))
.push(Router::with_path("/overrides").get(config_craft.get_overrides()))
.push(Router::with_path("/config").get(config_craft.get_config()))
.push(Router::with_path("/status").get(status_craft.get_status()))
.hoop(Logger::new());
if let Some(gui_path) = server_config.gui_path.clone() {
@@ -176,28 +182,84 @@ pub struct StatusCraft {
impl StatusCraft {
#[craft(handler)]
async fn get_status(&self) -> Json<ServerStatus> {
let addr = self.mumble_server_address;
match ping_server(&addr.ip().to_string(), addr.port()).await {
Ok(status) => Json(status),
let mut server_status = ServerStatus::default();
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) => {
error!("ping failed: {e:#}");
Json(ServerStatus::default())
error!("Could not bind udp socket: {}", e);
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)]
pub struct ConfigCraft {
server_config: Arc<Config>,
overrides: ProxyOverrides,
client_config: ClientConfig,
}
#[craft]
impl ConfigCraft {
#[craft(handler)]
async fn get_overrides(&self) -> Json<ProxyOverrides> {
Json(self.overrides.clone())
async fn get_config(&self) -> Json<ClientConfig> {
Json(self.client_config.clone())
}
#[craft(handler)]
@@ -258,7 +320,7 @@ async fn connect_proxy_impl(
) -> Result<()> {
info!("connecting to Mumble server...");
let config = ClientConfig::builder()
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
+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],
]
}
}