7 Commits

Author SHA1 Message Date
sam bc20cf825d fix version reporting after refactor (#34)
Build Mumble Web 2 / macos_build (push) Successful in 59s
Build Mumble Web 2 / linux_build (push) Successful in 1m20s
Build Mumble Web 2 / windows_build (push) Successful in 3m0s
Build Mumble Web 2 / android_build (push) Successful in 4m42s
Reviewed-on: #34
Reviewed-by: restitux <restitux@ohea.xyz>
Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2026-05-05 05:30:18 +00:00
sam e72bb6d4c4 Move mumble UDP ping into common crate (#33)
Build Mumble Web 2 / macos_build (push) Failing after 2s
Build Mumble Web 2 / linux_build (push) Failing after 0s
Build Mumble Web 2 / android_build (push) Failing after 3s
Build Mumble Web 2 / windows_build (push) Failing after 10s
The desktop GUI doesn't go through the proxy to reach the Mumble server,
so its login screen needs to ping directly. Rather than duplicate the
ping logic, move it into the common crate behind an optional
`networking` feature (so common stays lightweight when the feature
isn't requested).

- common: add `ping_server(address, port)` behind `networking` feature
- proxy: replace the inline UdpSocket ping + ping.rs codec with a call
  to common::ping_server; drop the rand dep
- client: PlatformInterface::get_status now takes an address; desktop
  and mobile call common::ping_server directly, web still uses the
  proxy's /status endpoint
- gui: thread the address from the login input through get_status, so
  it re-pings when the user edits the address

Assisted-by: claude-opus-4-7
Reviewed-on: #33
Reviewed-by: restitux <restitux@ohea.xyz>
Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2026-05-05 04:37:19 +00:00
restitux 63ce666fa7 build: add script to build android locally (#31)
Build Mumble Web 2 / windows_build (push) Failing after 31s
Build Mumble Web 2 / macos_build (push) Failing after 32s
Build Mumble Web 2 / linux_build (push) Failing after 30s
Build Mumble Web 2 / android_build (push) Failing after 28s
This change adds a shell script to make building the android APK locally a lot easier.

Reviewed-on: #31
Reviewed-by: Sam Sartor <cap@samsartor.com>
2026-05-05 04:14:33 +00:00
sam 3a9bb60605 Split into gui and client crates (#30)
Build Mumble Web 2 / macos_build (push) Successful in 56s
Build Mumble Web 2 / windows_build (push) Successful in 2m35s
Build Mumble Web 2 / linux_build (push) Successful in 1m21s
Build Mumble Web 2 / android_build (push) Successful in 4m45s
Reviewed-on: #30
Reviewed-by: restitux <restitux@ohea.xyz>
Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2026-05-05 03:23:22 +00:00
sam 7f35a216cd Persist denoise setting (#24)
Build android container / android-release-builder-container-build (push) Successful in 1s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 16s
Build Mumble Web 2 / macos_build (push) Successful in 1m2s
Build Mumble Web 2 / linux_build (push) Successful in 1m26s
Build Mumble Web 2 / windows_build (push) Successful in 3m23s
Build Mumble Web 2 / android_build (push) Successful in 4m59s
Puts the denoise bool into an AudioSettings struct in the model state, and persists changes to user state.

Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2026-03-30 01:30:14 +00:00
sam f0ce15000e Put model state into an Arc (#28)
Build Mumble Web 2 / macos_build (push) Successful in 1m14s
Build Mumble Web 2 / linux_build (push) Successful in 1m27s
Build Mumble Web 2 / windows_build (push) Successful in 2m56s
Build Mumble Web 2 / android_build (push) Successful in 4m39s
Previously the model state was in a `static STATE` to make it accessible to all the various subsystems. This moves it into an Arc and plumbs the reference around via function arguments. That allows us to do non-static initialization, eg based on user config. I also moved some things into dioxus context.

Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2026-03-30 00:56:36 +00:00
liamwarfield 7337b3e49b Quick fixes for S&T (#27)
Build Mumble Web 2 / macos_build (push) Successful in 1m14s
Build Mumble Web 2 / linux_build (push) Successful in 1m26s
Build Mumble Web 2 / windows_build (push) Successful in 2m51s
Build Mumble Web 2 / android_build (push) Successful in 4m34s
Some quick QAL changes I banged out this morning. The commit messages describe the individual changes in details.

## Changes

- Min window width on desktop.
- Removes white flash on desktop startup
- Removes right click menu on release builds (still exists on debug, and might come back in the future with new features).

Reviewed-on: #27
Reviewed-by: restitux <restitux@ohea.xyz>
2026-03-29 18:24:16 +00:00
30 changed files with 1375 additions and 1358 deletions
+1 -1
View File
@@ -6,4 +6,4 @@ server_hash.txt
proxy/bundle
/config.toml
proxy/config.toml
gui/assets/*_onnx.tar.gz
*_onnx.tar.gz
Generated
+26 -14
View File
@@ -1587,9 +1587,9 @@ dependencies = [
[[package]]
name = "dioxus-core-types"
version = "0.7.3"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f"
checksum = "b99d7d199aad72431b549759550002e7d72c8a257eba500dca9fbdb2122de103"
[[package]]
name = "dioxus-desktop"
@@ -4220,14 +4220,7 @@ dependencies = [
]
[[package]]
name = "mumble-web2-common"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "mumble-web2-gui"
name = "mumble-web2-client"
version = "0.1.0"
dependencies = [
"android-permissions",
@@ -4241,9 +4234,8 @@ dependencies = [
"crossbeam-queue",
"dasp_ring_buffer",
"deep_filter",
"dioxus",
"dioxus-asset-resolver",
"dioxus-web",
"dioxus-signals",
"etcetera",
"futures",
"futures-channel",
@@ -4252,6 +4244,7 @@ dependencies = [
"jni",
"js-sys",
"lol_html 2.7.0",
"manganis",
"markdown",
"merge-io",
"mime_guess",
@@ -4263,7 +4256,6 @@ dependencies = [
"opus",
"ordermap",
"reqwest",
"rfd 0.16.0",
"serde",
"serde-wasm-bindgen",
"serde_json",
@@ -4279,6 +4271,27 @@ 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"
@@ -4287,7 +4300,6 @@ 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 = ["common", "gui", "proxy"]
members = ["client", "common", "gui", "proxy"]
[workspace.dependencies]
serde = { version = "1.0.214", features = ["derive"] }
+146
View File
@@ -0,0 +1,146 @@
[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",
]
View File
+215
View File
@@ -0,0 +1,215 @@
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>;
+6 -16
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,11 +44,12 @@ 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(&DF_MODEL).await {
let model_bytes = match read_asset_bytes(&model).await {
Ok(b) => b,
Err(e) => {
error!("could not read denoising model from \"{DF_MODEL}\": {e:?}");
error!("could not read denoising model from \"{model}\": {e:?}");
return;
}
};
@@ -96,20 +97,9 @@ pub struct AudioProcessor {
}
impl AudioProcessor {
pub fn new_plain() -> Self {
pub fn new(denoise: bool) -> Self {
AudioProcessor {
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,
denoise,
spawn: SpawnHandle::current(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
@@ -1,6 +1,6 @@
use crate::app::Command;
use color_eyre::eyre::{bail, Error};
use dioxus::hooks::UnboundedReceiver;
use crate::app::{Command, SharedState};
use color_eyre::eyre::Error;
use futures_channel::mpsc::UnboundedReceiver;
use mumble_protocol::control::ClientControlCodec;
use std::net::ToSocketAddrs;
use std::sync::Arc;
@@ -14,7 +14,7 @@ use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{info, instrument};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use mumble_web2_common::ProxyOverrides;
#[derive(Debug)]
struct NoCertificateVerification;
@@ -74,6 +74,7 @@ pub async fn network_connect(
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
info!("connecting");
@@ -102,11 +103,9 @@ pub async fn network_connect(
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
crate::network_loop(username, event_rx, reader, writer).await
}
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
bail!("status not supported on desktop yet")
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
}
#[allow(unused)]
@@ -1,9 +1,7 @@
use crate::app::Command;
use crate::app::{Command, SharedState};
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use futures_channel::mpsc::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::collections::HashMap;
use std::time::Duration;
/// Desktop platform implementation using Tokio and native audio.
@@ -30,12 +28,16 @@ impl super::PlatformInterface for DesktopPlatform {
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, overrides).await
super::connect::network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
super::connect::get_status(client).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() {
@@ -1,6 +1,6 @@
use crate::app::Command;
use crate::app::{Command, SharedState};
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use futures_channel::mpsc::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::time::Duration;
@@ -24,12 +24,16 @@ impl super::PlatformInterface for MobilePlatform {
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, overrides).await
super::connect::network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
super::connect::get_status(client).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() {
+11 -4
View File
@@ -4,9 +4,10 @@
//! The traits make the platform boundary explicit and provide compile-time verification.
#![allow(async_fn_in_trait)]
use crate::{app::Command, effects::AudioProcessor};
use crate::app::{Command, SharedState};
use crate::effects::AudioProcessor;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use futures_channel::mpsc::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::collections::HashMap;
use std::future::Future;
@@ -51,7 +52,7 @@ pub trait AudioPlayerInterface {
fn play_opus(&mut self, payload: &[u8]);
}
pub trait ConfigSystemInterface: Sized {
pub trait ConfigSystemInterface: Sized + Clone {
fn new() -> Result<Self, Error>;
fn config_get<T>(&self, key: &str) -> Option<T>
@@ -82,11 +83,17 @@ pub trait PlatformInterface {
username: String,
event_rx: &mut UnboundedReceiver<Command>,
proxy_overrides: &ProxyOverrides,
state: SharedState,
) -> impl Future<Output = Result<(), Error>>;
/// Get server status (user count, version, etc.).
/// 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.
fn get_status(
client: &reqwest::Client,
address: &str,
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
/// Load the proxy overrides (proxy URL, cert hash, etc.).
@@ -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_plain();
let mut current_processor = AudioProcessor::new(false);
let mut output_buffer = Vec::new();
let processors = self.processors.clone();
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
@@ -1,10 +1,6 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::ServerStatus;
use std::collections::HashMap;
use std::time::Duration;
use tracing::{error, info, warn};
use tracing::info;
#[derive(Clone, PartialEq)]
pub struct NativeConfigSystem {
@@ -1,8 +1,8 @@
/// Stub implementation of the platform interface, so that we can
/// `cargo check` without any --feature flags.
use crate::effects::AudioProcessor;
use crate::{app::SharedState, effects::AudioProcessor};
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use futures_channel::mpsc::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::future::Future;
@@ -25,12 +25,14 @@ impl super::PlatformInterface for StubPlatform {
_username: String,
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
_overrides: &ProxyOverrides,
_state: SharedState,
) -> 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") }
}
@@ -77,6 +79,7 @@ impl super::AudioPlayerInterface for StubAudioPlayer {
}
}
#[derive(Clone)]
pub struct StubConfigSystem;
impl super::ConfigSystemInterface for StubConfigSystem {
+22 -11
View File
@@ -1,10 +1,11 @@
use crate::app::Command;
use crate::app::{Command, SharedState};
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{bail, eyre, Error};
use crossbeam::atomic::AtomicCell;
use dioxus::prelude::*;
use futures_channel::mpsc::UnboundedReceiver;
use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array;
use manganis::asset;
use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use reqwest::Url;
@@ -111,11 +112,15 @@ impl super::PlatformInterface for WebPlatform {
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
network_connect(address, username, event_rx, overrides).await
network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
async fn get_status(
client: &reqwest::Client,
_address: &str,
) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(absolute_url("status")?)
.send()
@@ -156,7 +161,7 @@ pub struct WebAudioSystem {
processors: AudioProcessorSender,
}
async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
async fn attach_worklet(audio_context: &AudioContext, worklet_url: &str) -> Result<(), Error> {
// Create worklets to process mic and speaker audio
// Speaker audio processing worklet only required on
// browsers that don't support MediaStreamTrackGenerator
@@ -169,12 +174,11 @@ async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
)
.ey()?;
let module = asset!("assets/rust_audio_worklet.js").to_string();
info!("loading mic worklet from {module:?}");
info!("loading mic worklet from {worklet_url:?}");
audio_context
.audio_worklet()
.ey()?
.add_module_with_options(&module, &options)
.add_module_with_options(worklet_url, &options)
.ey()?
.into_future()
.await
@@ -189,7 +193,11 @@ 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).await?;
attach_worklet(
&webctx,
&asset!("/assets/rust_audio_worklet.js").to_string(),
)
.await?;
let processors = AudioProcessorSender::default();
@@ -380,7 +388,7 @@ async fn run_encoder_worklet(
audio_encoder.configure(&encoder_config);
info!("created audio encoder");
let mut current_processor = AudioProcessor::new_plain();
let mut current_processor = AudioProcessor::new(false);
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
if let Some(new_processor) = processors.take() {
current_processor = new_processor;
@@ -434,6 +442,7 @@ pub async fn network_connect(
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
info!("connecting");
@@ -492,7 +501,9 @@ pub async fn network_connect(
let writer =
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
crate::network_loop(username, event_rx, reader, writer).await
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
}
pub fn absolute_url(path: &str) -> Result<Url, Error> {
+13
View File
@@ -0,0 +1,13 @@
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");
+59 -54
View File
@@ -1,19 +1,20 @@
use app::Chat;
use app::Command;
use app::ConnectionState;
use app::STATE;
use crate::msghtml::process_message_html;
use crate::AudioSettings;
use crate::Chat;
use crate::Command;
use crate::ConnectionState;
use asynchronous_codec::FramedRead;
use asynchronous_codec::FramedWrite;
use color_eyre::eyre::{bail, Error};
use dioxus::prelude::*;
use dioxus_signals::ReadableExt as _;
use dioxus_signals::WritableExt as _;
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::UnboundedSender;
use msghtml::process_message_html;
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
use mumble_protocol::control::msgs;
use mumble_protocol::control::ControlCodec;
use mumble_protocol::control::ControlPacket;
@@ -27,18 +28,15 @@ 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::{
AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
PlatformInterface as _,
spawn, AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _,
Platform, PlatformInterface as _,
};
pub mod app;
mod effects;
pub mod imp;
mod msghtml;
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>, state: SharedState) {
loop {
let Some(Command::Connect {
address,
@@ -49,28 +47,25 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
panic!("did not receive connect command")
};
*STATE.server.write() = Default::default();
*STATE.status.write() = ConnectionState::Connecting;
*state.server.write_unchecked() = Default::default();
*state.status.write_unchecked() = ConnectionState::Connecting;
if let Err(error) =
Platform::network_connect(address, username, &mut event_rx, &config).await
Platform::network_connect(address, username, &mut event_rx, &config, state.clone())
.await
{
error!("could not connect {:?}", error);
*STATE.status.write() = ConnectionState::Failed(error.to_string());
*state.status.write_unchecked() = ConnectionState::Failed(error.to_string());
} else {
*STATE.status.write() = ConnectionState::Disconnected;
*state.status.write_unchecked() = ConnectionState::Disconnected;
}
}
}
pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin + 'static>(
username: String,
event_rx: &mut UnboundedReceiver<Command>,
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
pub(crate) async fn sender_loop<W: AsyncWrite + Unpin + 'static>(
mut outgoing: UnboundedReceiver<ControlPacket<Serverbound>>,
mut writer: FramedWrite<W, ControlCodec<Serverbound, Clientbound>>,
) -> Result<(), Error> {
let (mut send_chan, mut writer_recv_chan) = futures_channel::mpsc::unbounded();
spawn(async move {
while let Some(msg) = writer_recv_chan.next().await {
) {
while let Some(msg) = outgoing.next().await {
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
info!("sending packet {:#?}", msg);
}
@@ -79,7 +74,16 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
break;
}
}
});
}
pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
username: String,
state: SharedState,
event_rx: &mut UnboundedReceiver<Command>,
mut outgoing: UnboundedSender<ControlPacket<Serverbound>>,
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
) -> Result<(), Error> {
let audio_settings = state.audio.read().clone();
// Get version packet
let version = match reader.next().await {
@@ -94,17 +98,17 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
msg.set_version(0x000010204);
msg.set_release(format!("{} {}", "mumbleweb2", "6.9.0"));
//msg.set_os("Chrome".to_string());
send_chan.send(msg.into()).await.unwrap();
outgoing.send(msg.into()).await.unwrap();
// Send authenticate packet
let mut msg = msgs::Authenticate::new();
msg.set_username(username);
msg.set_opus(true);
send_chan.send(msg.into()).await.unwrap();
outgoing.send(msg.into()).await.unwrap();
// Spawn worker to send pings
{
let mut send_chan = send_chan.clone();
let mut send_chan = outgoing.clone();
spawn(async move {
loop {
if let Err(_) = send_chan.send(msgs::Ping::new().into()).await {
@@ -117,10 +121,11 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
}
let mut audio = AudioSystem::new().await?;
audio.set_processor(AudioProcessor::new(audio_settings.denoise));
{
let send_chan = send_chan.clone();
let send_chan = outgoing.clone();
let mut sequence_num = 0;
audio.start_recording(move |opus_frame, is_terminator| {
if let Err(err) = audio.start_recording(move |opus_frame, is_terminator| {
let _ =
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
_dst: std::marker::PhantomData,
@@ -131,7 +136,9 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
position_info: None,
})));
sequence_num = sequence_num.wrapping_add(2);
});
}) {
error!("could not begin recording: {err:?}")
}
}
// Create map of session_id -> AudioDecoder
@@ -149,7 +156,7 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) {
info!("receiving packet {:#?}", msg);
}
let res = accept_packet(msg, &mut audio, &mut decoder_map);
let res = accept_packet(msg, &mut audio, &mut decoder_map, &state);
if let Err(err) = res {
error!("error accepting packet {:?}", err)
}
@@ -168,7 +175,7 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
match command {
Some(Command::Disconnect) => break,
Some(command) => {
let res = accept_command(command, &mut send_chan, &mut audio);
let res = accept_command(command, &mut outgoing, &mut audio, &state);
if let Err(err) = res {
info!("error accepting command {:?}", err)
}
@@ -178,7 +185,7 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
}
}
}
let _ = send_chan.close();
let _ = outgoing.close();
Ok(())
}
@@ -187,9 +194,10 @@ 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")
};
@@ -212,7 +220,7 @@ fn accept_command(
};
{
let mut server = STATE.server.write();
let mut server = state.server.write_unchecked();
let Some(me) = server.session else {
bail!("not signed in with a session id")
};
@@ -253,7 +261,7 @@ fn accept_command(
};
{
let mut server = STATE.server.write();
let mut server = state.server.write_unchecked();
let Some(me) = server.session else {
bail!("not signed in with a session id")
};
@@ -288,12 +296,8 @@ fn accept_command(
let _ = send_chan.unbounded_send(u.into());
}
Connect { .. } | Disconnect => (),
UpdateMicEffects { denoise } => {
if denoise {
audio.set_processor(AudioProcessor::new_denoising());
} else {
audio.set_processor(AudioProcessor::new_plain());
}
UpdateAudioSettings(AudioSettings { denoise }) => {
audio.set_processor(AudioProcessor::new(denoise));
}
}
@@ -304,6 +308,7 @@ 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) => {
@@ -340,15 +345,15 @@ fn accept_packet(
}
}
ControlPacket::ChannelState(u) => {
let mut server = STATE.server.write();
let mut server = state.server.write_unchecked();
server.channels_state.update_from_channel_state(&u);
}
ControlPacket::ChannelRemove(u) => {
let mut server = STATE.server.write();
let mut server = state.server.write_unchecked();
server.channels_state.update_from_channel_remove(&u);
}
ControlPacket::UserState(u) => {
let mut server = STATE.server.write();
let mut server = state.server.write_unchecked();
let server = &mut *server;
let id = u.get_session();
@@ -392,7 +397,7 @@ fn accept_packet(
}
}
ControlPacket::UserRemove(u) => {
let mut server = STATE.server.write();
let mut server = state.server.write_unchecked();
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) {
@@ -401,7 +406,7 @@ fn accept_packet(
}
}
ControlPacket::TextMessage(u) => {
let mut server = STATE.server.write();
let mut server = state.server.write_unchecked();
if u.has_message() {
let text = u.get_message().to_string();
server.chat.push(Chat {
@@ -416,8 +421,8 @@ fn accept_packet(
}
}
ControlPacket::ServerSync(u) => {
*STATE.status.write() = ConnectionState::Connected;
let mut server = STATE.server.write();
*state.status.write_unchecked() = ConnectionState::Connected;
let mut server = state.server.write_unchecked();
if u.has_welcome_text() {
let text = u.get_welcome_text().to_string();
server.chat.push(Chat {
+5
View File
@@ -3,5 +3,10 @@ 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 }
+59
View File
@@ -16,3 +16,62 @@ pub struct ServerStatus {
pub max_users: 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::net::ToSocketAddrs;
use std::time::Duration;
use tokio::net::UdpSocket;
let dest = format!("{}:{}", address, port)
.to_socket_addrs()?
.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"),
}
}
+21
View File
@@ -0,0 +1,21 @@
#!/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"
+6 -129
View File
@@ -4,103 +4,11 @@ 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" }
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"
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" }
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
# ====================
@@ -108,50 +16,19 @@ crossbeam = "0.8.4"
[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",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"serde-wasm-bindgen",
"js-sys",
"web-sys",
"gloo-timers",
"tracing-web",
"deep_filter/wasm",
"mumble-web2-client/web",
"rfd",
]
desktop = [
"dioxus/desktop",
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
"mumble-web2-client/desktop",
"rfd/xdg-portal",
"etcetera",
]
mobile = [
"dioxus/mobile",
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
"mumble-web2-client/mobile"
]
+1
View File
@@ -16,6 +16,7 @@ body {
}
#main {
visibility: visible;
height: 100vh;
display: flex;
flex-direction: column;
-882
View File
@@ -1,882 +0,0 @@
#![allow(non_snake_case)]
use dioxus::prelude::*;
use mime_guess::Mime;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use ordermap::OrderSet;
use std::collections::{HashMap, HashSet};
use crate::imp::{ConfigSystem, ConfigSystemInterface as _, 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: 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,
},
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(overrides: Resource<ProxyOverrides>) -> 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 = 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"
}
}
}
},
};
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(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem) -> 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 { overrides }
}
}
)
}
#[component]
pub fn LoginView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem) -> 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(|| 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 previous_username = user_config.config_get::<String>("username");
let mut username = use_signal(|| previous_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 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 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 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 overrides = use_resource(|| async move {
match Platform::load_proxy_overrides().await {
Ok(overrides) => overrides,
Err(_) => ProxyOverrides::default(),
}
});
let user_config = ConfigSystem::new().unwrap();
Platform::request_permissions();
rsx!(
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" }
document::Link{ rel: "stylesheet", href: STYLE }
match *STATE.status.read() {
Connected => rsx!(ServerView { overrides, user_config }),
_ => rsx!(LoginView { overrides, user_config }),
}
)
}
+736 -2
View File
@@ -1,6 +1,740 @@
use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
#![allow(non_snake_case)]
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() {
Platform::init_logging();
dioxus::launch(app::app);
dioxus::LaunchBuilder::new()
.with_cfg(desktop! {
dioxus::desktop::Config::new()
// Reduce white flash on startup by setting background color and hiding main element
.with_background_color((0, 0, 0, 255))
.with_custom_head("<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);
}
+1 -2
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 }
mumble-web2-common = { workspace = true, features = ["networking"] }
salvo = { version = "^0.84.2", features = [
"quinn",
"eyre",
@@ -28,4 +28,3 @@ rcgen = "^0.13.2"
hmac-sha256 = "^1.1.8"
time = "0.3"
url = { version = "2", features = ["serde"] }
rand = "0.9.2"
+6 -65
View File
@@ -1,6 +1,5 @@
use color_eyre::eyre::{anyhow, bail, Context, Result};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use rand::Rng;
use mumble_web2_common::{ping_server, ProxyOverrides, ServerStatus};
use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::cors::{AllowOrigin, Cors};
use salvo::logging::Logger;
@@ -26,8 +25,6 @@ use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::EnvFilter;
use url::Url;
mod ping;
fn default_cert_alt_names() -> Vec<String> {
vec!["localhost".into()]
}
@@ -179,70 +176,14 @@ pub struct StatusCraft {
impl StatusCraft {
#[craft(handler)]
async fn get_status(&self) -> Json<ServerStatus> {
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,
let addr = self.mumble_server_address;
match ping_server(&addr.ip().to_string(), addr.port()).await {
Ok(status) => Json(status),
Err(e) => {
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);
error!("ping failed: {e:#}");
Json(ServerStatus::default())
}
}
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)
}
}
-141
View File
@@ -1,141 +0,0 @@
// 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],
]
}
}