1 Commits

Author SHA1 Message Date
restitux dab00dce2f disable rust cache in linux ci
Build Mumble Web 2 / windows_build (push) Successful in 2m46s
Build Mumble Web 2 / linux_build (push) Successful in 4m20s
Build Mumble Web 2 / android_build (push) Successful in 5m56s
2026-01-24 22:00:11 -07:00
24 changed files with 486 additions and 1307 deletions
-1
View File
@@ -1 +0,0 @@
target
+2 -43
View File
@@ -18,9 +18,9 @@ jobs:
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
run: cargo binstall dioxus-cli --version 0.7.2
- uses: Swatinem/rust-cache@v2
#- uses: Swatinem/rust-cache@v2
- name: Build dioxus project
run: dx build --platform web --release -p mumble-web2-gui
@@ -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
@@ -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,
+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
}
+1 -1
View File
@@ -29,7 +29,7 @@ RUN yes | /opt/android-tools/cmdline-tools/latest/bin/sdkmanager --install "plat
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
# Install dioxus-cli
RUN cargo binstall dioxus-cli@0.7.3
RUN cargo binstall dioxus-cli@0.7.2
# Install bindgen-cli
RUN cargo binstall bindgen-cli
-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: >
+5 -3
View File
@@ -44,12 +44,14 @@ RUN choco install rustup.install -y --no-progress
RUN rustup toolchain install stable-x86_64-pc-windows-msvc
RUN rustup default stable-x86_64-pc-windows-msvc
# Install cargo binstall
# Install carog binstall
RUN Set-ExecutionPolicy Unrestricted -Scope Process; `
iex (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
# Install dioxus-cli
RUN cargo binstall dioxus-cli@0.7.3
# Install dioxus-cli from git HEAD with cargo
# This is to work around a bug in the windows builder upstream.
# Dioxus has released 0.7.2, but it seems to be broken for now.
RUN cargo binstall dioxus-cli
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
+1
View File
@@ -146,6 +146,7 @@ desktop = [
"rfd/xdg-portal",
"etcetera",
]
mobile = [
"dioxus/mobile",
"tokio",
+1 -40
View File
@@ -16,7 +16,6 @@ body {
}
#main {
visibility: visible;
height: 100vh;
display: flex;
flex-direction: column;
@@ -84,44 +83,6 @@ a:visited {
}
}
.channel_header {
display: flex;
flex-direction: row;
align-items: center;
}
.channel_arrow {
width: 1em;
text-align: center;
margin-right: 0.25rem;
}
.channel_arrow--placeholder {
pointer-events: none;
visibility: hidden;
}
/* The whole right side of the row is the dblclick target */
.channel_row_click {
flex: 1;
padding: 0.1rem 0.25rem 0.1rem 0.5rem;
cursor: pointer;
}
/* Hover highlight for whole row area (title + blank space) */
.channel_row_click:hover {
background-color: var(--channel-hover-bg, #222); /* pick your color */
}
/* still keep text non-selectable if desired */
.channel_details {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.channel {
&_details {
flex: 0 0 100%;
@@ -431,4 +392,4 @@ a:visited {
color: red;
}
}
}
}
+79 -262
View File
@@ -2,17 +2,15 @@
use dioxus::prelude::*;
use mime_guess::Mime;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use mumble_web2_common::{ClientConfig, ServerStatus};
use ordermap::OrderSet;
use std::collections::{HashMap, HashSet};
use std::{fmt, sync::Arc};
use std::collections::HashMap;
use crate::imp::{ConfigSystem, ConfigSystemInterface as _, Platform, PlatformInterface as _};
use crate::imp;
pub type ChannelId = u32;
pub type UserId = u32;
#[derive(Debug)]
pub enum ConnectionState {
Disconnected,
Connecting,
@@ -20,17 +18,12 @@ pub enum ConnectionState {
Failed(String),
}
#[derive(Debug, Clone)]
pub struct AudioSettings {
pub denoise: bool,
}
#[derive(Debug)]
pub enum Command {
Connect {
address: String,
username: String,
config: ProxyOverrides,
config: ClientConfig,
},
SendChat {
markdown: String,
@@ -52,14 +45,24 @@ pub enum Command {
channel: ChannelId,
user: UserId,
},
UpdateAudioSettings(AudioSettings),
UpdateMicEffects {
denoise: bool,
},
Disconnect,
}
use Command::*;
use ConnectionState::*;
#[derive(Default, Debug)]
#[derive(Default)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
}
#[derive(Default)]
pub struct UserState {
pub name: String,
pub channel: ChannelId,
@@ -84,129 +87,15 @@ impl UserState {
}
}
#[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)]
#[derive(Default)]
pub struct ServerState {
pub channels_state: ChannelsState,
pub channels: HashMap<ChannelId, ChannelState>,
pub users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>,
pub session: Option<UserId>,
@@ -219,21 +108,14 @@ impl ServerState {
}
pub struct State {
pub status: Signal<ConnectionState>,
pub server: Signal<ServerState>,
pub audio: Signal<AudioSettings>,
pub status: GlobalSignal<ConnectionState>,
pub server: GlobalSignal<ServerState>,
}
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>;
pub static STATE: State = State {
status: Signal::global(|| Disconnected),
server: Signal::global(|| Default::default()),
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum UserIcon {
@@ -280,8 +162,7 @@ pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
#[component]
pub fn User(id: UserId) -> Element {
let state = use_context::<SharedState>();
let server = state.server.read();
let server = STATE.server.read();
match server.users.get(&id) {
Some(state) => rsx!(UserPill {
name: state.name.clone(),
@@ -299,60 +180,28 @@ pub fn User(id: UserId) -> Element {
#[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 server = STATE.server.read();
let user = server.session.unwrap();
let Some(state) = server.channels_state.channels.get(&id) else {
let Some(state) = server.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 {
details {
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| {
open: true,
summary {
span {
role: "button",
ondoubleclick: 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
"{state.name}"
}
}
if *open.read() && has_children {
if state.users.len() + state.children.len() > 0 {
div {
class: "channel_children",
for id in state.users.iter() {
@@ -369,8 +218,7 @@ pub fn Channel(id: ChannelId) -> Element {
#[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() {
let channels = if let Some(user) = STATE.server.read().this_user() {
vec![user.channel]
} else {
return;
@@ -396,14 +244,11 @@ 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 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() {
if let Some(user) = STATE.server.read().this_user() {
net.send(SendChat {
markdown: draft.write().split_off(0),
channels: vec![user.channel],
@@ -473,12 +318,10 @@ pub fn ChatView() -> Element {
}
#[component]
pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
pub fn ControlView(config: Resource<ClientConfig>) -> 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 status = &STATE.status;
let server = STATE.server.read();
let Some(&UserState {
deaf,
self_deaf,
@@ -493,12 +336,12 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
return rsx!();
};
let current_channel_name = server.channels_state.channels[&channel].name.clone();
let current_channel_name = server.channels[&channel].name.clone();
let proxy_url = overrides
let proxy_url = config
.read_unchecked()
.as_ref()
.and_then(|overrides| overrides.proxy_url.clone());
.and_then(|gui_config| gui_config.proxy_url.clone());
let connecting_color = "yellow";
let connected_color = "oklch(0.55 0.1184 141.35)";
@@ -576,6 +419,7 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
},
};
let denoise = use_signal(|| false);
rsx!(
// Server control
div {
@@ -616,23 +460,18 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
}
span { class: "spacer" }
button {
class: match audio.denoise {
class: match denoise() {
true => "toggle_button is_on",
false => "toggle_button",
},
role: "switch",
aria_checked: audio.denoise,
aria_checked: denoise(),
onclick: move |_| {
let state = use_context::<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);
let new_denoise = !denoise();
*denoise.write_unchecked() = new_denoise;
net.send(UpdateMicEffects { denoise: new_denoise })
},
match audio.denoise {
match denoise() {
true => rsx!(span { class: "material-symbols-outlined", "cadence"}),
false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}),
}
@@ -670,10 +509,9 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
}
#[component]
pub fn ServerView(overrides: Resource<ProxyOverrides>) -> Element {
pub fn ServerView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let state = use_context::<SharedState>();
let server = state.server.read();
let server = STATE.server.read();
let Some(&UserState {
deaf,
self_deaf,
@@ -690,7 +528,7 @@ pub fn ServerView(overrides: Resource<ProxyOverrides>) -> Element {
class: "server_grid",
div {
class: "server_channel_box",
for (id, state) in server.channels_state.channels.iter() {
for (id, state) in server.channels.iter() {
if state.parent.is_none() {
Channel { id: *id }
}
@@ -702,56 +540,52 @@ pub fn ServerView(overrides: Resource<ProxyOverrides>) -> Element {
}
div {
class: "server_control_box",
ControlView { overrides }
ControlView { config }
}
}
)
}
#[component]
pub fn LoginView(overrides: Resource<ProxyOverrides>) -> Element {
let user_config = use_context::<ConfigSystem>();
pub fn LoginView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
use_resource(move || async move {
let client = reqwest::Client::new();
loop {
*last_status.write_unchecked() = Some(Platform::get_status(&client).await);
Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await;
*last_status.write_unchecked() = Some(imp::get_status(&client).await);
imp::sleep(std::time::Duration::from_secs_f32(1.0)).await;
}
});
let mut address_input = use_signal(|| user_config.config_get::<String>("server_url"));
let mut address_input = use_signal(|| imp::load_server_url());
let address = use_memo(move || {
if let Some(addr) = address_input() {
addr.clone()
} else {
overrides()
config()
.and_then(|c| c.proxy_url.clone())
.unwrap_or_default()
}
});
let mut username = use_signal(|| {
user_config
.config_get::<String>("username")
.unwrap_or(String::new())
});
let previous_username = imp::load_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());
//let _ = set_default_username(&username.read());
let _ = imp::set_default_username(&username.read());
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
imp::set_default_server(&address.read());
}
net.send(Connect {
address: address.read().clone(),
username: username.read().clone(),
config: overrides.read().clone().unwrap_or_default(),
config: config.read().clone().unwrap_or_default(),
})
};
let state = use_context::<SharedState>();
let status = &state.status;
let status = &STATE.status;
let bottom = match &*status.read() {
Disconnected => rsx! {
button {
@@ -793,7 +627,7 @@ pub fn LoginView(overrides: Resource<ProxyOverrides>) -> Element {
None => rsx!(),
}
}
if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) {
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
div {
label {
for: "address-entry",
@@ -885,44 +719,27 @@ pub fn LoginView(overrides: Resource<ProxyOverrides>) -> Element {
// )
}
#[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>| {
super::network_entrypoint(rx, network_state.clone())
});
let overrides = use_resource(|| async move {
match Platform::load_proxy_overrides().await {
Ok(overrides) => overrides,
Err(_) => ProxyOverrides::default(),
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
let config = use_resource(|| async move {
match imp::load_config().await {
Ok(config) => config,
Err(_) => ClientConfig::default(),
}
});
imp::request_permissions();
rsx!(
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" }
document::Link{ rel: "stylesheet", href: STYLE }
match *state.status.read() {
Connected => rsx!(ServerView { overrides }),
_ => rsx!(LoginView { overrides }),
match *STATE.status.read() {
Connected => rsx!(ServerView { config }),
_ => rsx!(LoginView { config }),
}
)
}
+9 -11
View File
@@ -7,7 +7,7 @@ use std::cell::RefCell;
use std::sync::Arc;
use tracing::{error, info};
use crate::imp::SpawnHandle;
use crate::imp;
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
// TODO: make this user configurable.
@@ -32,7 +32,10 @@ enum DenoisingModelState {
Availible(Box<DfTract>),
}
fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract) -> O) -> Option<O> {
fn with_denoising_model<O>(
spawn: &imp::SpawnHandle,
func: impl FnOnce(&mut DfTract) -> O,
) -> Option<O> {
// Using a thread local is super gross, but DfTract is not Send (so it can never leave the current
// thread) while AudioProcessing itself might change threads whenever.
thread_local! {
@@ -86,7 +89,7 @@ fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract)
pub struct AudioProcessor {
denoise: bool,
spawn: SpawnHandle,
spawn: imp::SpawnHandle,
buffer: Vec<f32>,
noise_floor: f32,
/// Whether we were transmitting in the previous frame
@@ -99,7 +102,7 @@ impl AudioProcessor {
pub fn new_plain() -> Self {
AudioProcessor {
denoise: false,
spawn: SpawnHandle::current(),
spawn: imp::SpawnHandle::current(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
@@ -110,7 +113,7 @@ impl AudioProcessor {
pub fn new_denoising() -> Self {
AudioProcessor {
denoise: true,
spawn: SpawnHandle::current(),
spawn: imp::SpawnHandle::current(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
@@ -120,12 +123,7 @@ impl AudioProcessor {
}
impl AudioProcessor {
pub fn process(
&mut self,
audio: &[f32],
channels: usize,
output: &mut Vec<f32>,
) -> TransmitState {
pub fn process(&mut self, audio: &[f32], channels: usize, output: &mut Vec<f32>) -> TransmitState {
let mut include_raw = true;
if self.denoise {
with_denoising_model(&self.spawn, |df| {
-91
View File
@@ -1,91 +0,0 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::future::Future;
use std::time::Duration;
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
pub struct MobilePlatform;
impl super::PlatformInterface for MobilePlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
fn load_username() -> Option<String> {
None
}
fn load_server_url() -> Option<String> {
None
}
fn set_default_username(_username: &str) -> Option<()> {
None
}
fn set_default_server(server: &str) -> Option<()> {
None
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, gui_config).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
super::connect::get_status(client).await
}
fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
fn request_permissions() {
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();
}
}
+6 -12
View File
@@ -1,4 +1,4 @@
use crate::app::{Command, SharedState};
use crate::app::Command;
use color_eyre::eyre::{bail, Error};
use dioxus::hooks::UnboundedReceiver;
use mumble_protocol::control::ClientControlCodec;
@@ -8,13 +8,13 @@ use tokio::net::TcpStream;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::ClientConfig;
use tokio_rustls::rustls::ClientConfig as RlsClientConfig;
use tokio_rustls::rustls::DigitallySignedStruct;
use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{info, instrument};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use mumble_web2_common::{ClientConfig, ServerStatus};
#[derive(Debug)]
struct NoCertificateVerification;
@@ -73,12 +73,11 @@ pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
gui_config: &ClientConfig,
) -> Result<(), Error> {
info!("connecting");
let config = ClientConfig::builder()
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
@@ -103,14 +102,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, state, event_rx, reader, writer).await
crate::network_loop(username, event_rx, reader, writer).await
}
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
bail!("status not supported on desktop yet")
}
#[allow(unused)]
pub use tokio::spawn;
#[allow(unused)]
pub type SpawnHandle = tokio::runtime::Handle;
+77 -53
View File
@@ -1,58 +1,82 @@
use crate::app::{Command, SharedState};
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::time::Duration;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use mumble_web2_common::ClientConfig;
use std::collections::HashMap;
pub use tokio::runtime::Handle as SpawnHandle;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
/// Desktop platform implementation using Tokio and native audio.
pub struct DesktopPlatform;
pub use super::connect::*;
pub use super::native_audio::*;
impl super::PlatformInterface for DesktopPlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
type ConfigSystem = super::native_config::NativeConfigSystem;
fn get_config_path() -> std::path::PathBuf {
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "com".to_string(),
author: "Ohea Corp".to_string(),
app_name: "Mumble Web2".to_string(),
})
.expect("failed to choose app strategy");
strategy.config_dir().join("config.json")
}
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
Ok(ProxyOverrides {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
super::connect::get_status(client).await
}
fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
fn request_permissions() {
// No-op on desktop
fn 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(())
}
pub 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()
}
pub 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()
}
pub fn load_username() -> Option<String> {
let config = load_config_map();
config.get("username").cloned()
}
pub fn load_server_url() -> Option<String> {
let config = load_config_map();
config.get("server").cloned()
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
pub 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();
}
+50 -59
View File
@@ -1,70 +1,61 @@
use crate::app::{Command, SharedState};
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::time::Duration;
use android_permissions::{PermissionManager, RECORD_AUDIO};
use jni::{objects::JObject, JavaVM};
use mumble_web2_common::ClientConfig;
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
pub struct MobilePlatform;
use std::collections::HashMap;
pub use tokio::runtime::Handle as SpawnHandle;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
impl super::PlatformInterface for MobilePlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
type ConfigSystem = super::native_config::NativeConfigSystem;
pub use super::connect::*;
pub use super::native_audio::*;
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
Ok(ProxyOverrides {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
super::connect::get_status(client).await
}
fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
fn request_permissions() {
request_recording_permission();
}
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
pub fn set_default_username(username: &str) -> Option<()> {
None
}
#[cfg(not(target_os = "android"))]
pub fn request_recording_permission() {}
pub fn set_default_server(server: &str) -> Option<()> {
None
}
pub fn load_username() -> Option<String> {
None
}
pub fn load_server_url() -> Option<String> {
None
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
pub 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();
}
#[cfg(feature = "mobile")]
pub fn request_permissions() {
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()) };
+14 -172
View File
@@ -1,187 +1,29 @@
//! Platform abstraction layer
//!
//! This module defines traits that each platform (web, desktop, mobile) must implement.
//! The traits make the platform boundary explicit and provide compile-time verification.
#![allow(async_fn_in_trait)]
use crate::app::{Command, SharedState};
use crate::effects::AudioProcessor;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::collections::HashMap;
use std::future::Future;
use std::time::Duration;
// ============================================================================
// Trait Definitions
// ============================================================================
/// Platform-specific audio subsystem for capturing microphone input and creating playback streams.
///
/// The audio system handles Opus encoding internally - callers receive encoded frames
/// ready for network transmission.
pub trait AudioSystemInterface: Sized {
/// The player type returned by [`create_player`](Self::create_player).
type AudioPlayer: AudioPlayerInterface;
/// Initialize the audio system.
async fn new() -> Result<Self, Error>;
/// Set the processor for the microphone input, mainly noise cancellation settings.
fn set_processor(&self, processor: AudioProcessor);
/// Begin listening to microphone input, calling the `each` function with
/// encoded opus frames.
fn start_recording(
&mut self,
each: impl FnMut(Vec<u8>, bool) + Send + 'static,
) -> Result<(), Error>;
/// Begin playback of an audio stream, returning an object that can be passed opus frames.
fn create_player(&mut self) -> Result<Self::AudioPlayer, Error>;
}
/// A handle to an active audio playback stream for a single remote user.
///
/// Each connected user gets their own `AudioPlayer` instance, which decodes
/// incoming Opus frames and outputs PCM audio to the platform's audio device.
/// The player manages its own decoder state and output buffer.
pub trait AudioPlayerInterface {
/// Decode and play an Opus-encoded audio frame.
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();
/// Request runtime permissions (Android audio recording, etc.).
fn request_permissions();
/// Establish a connection to the Mumble server and run the network loop.
fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
proxy_overrides: &ProxyOverrides,
state: SharedState,
) -> impl Future<Output = Result<(), Error>>;
/// Get server status (user count, version, etc.).
fn get_status(
client: &reqwest::Client,
) -> 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>>;
/// Async sleep for the given duration.
fn sleep(duration: Duration) -> impl Future<Output = ()>;
}
// ============================================================================
// Platform Modules
// ============================================================================
mod stub;
#[cfg(feature = "web")]
mod web;
#[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(feature = "web")]
mod web;
#[cfg(feature = "desktop")]
pub use desktop::*;
#[cfg(feature = "mobile")]
pub use mobile::*;
// ============================================================================
// Platform Type Alias
// ============================================================================
#[cfg(feature = "mobile")]
pub use mobile::request_permissions;
#[cfg(feature = "web")]
pub type Platform = web::WebPlatform;
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn request_permissions() {}
#[cfg(all(feature = "desktop", not(feature = "web")))]
pub type Platform = desktop::DesktopPlatform;
#[cfg(all(feature = "web", not(any(feature = "desktop", feature = "mobile"))))]
pub use web::*;
#[cfg(all(feature = "mobile", not(feature = "web"), not(feature = "desktop")))]
pub type Platform = mobile::MobilePlatform;
#[cfg(all(
not(feature = "mobile"),
not(feature = "web"),
not(feature = "desktop")
))]
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
// ========================
// Note: these can not be part of the Platform because they differ in Send requiremets
#[cfg(all(any(feature = "desktop", feature = "mobile"), not(feature = "web")))]
pub use connect::{spawn, SpawnHandle};
#[cfg(all(
not(feature = "desktop"),
not(feature = "mobile"),
not(feature = "web")
))]
pub use stub::{spawn, SpawnHandle};
#[cfg(feature = "web")]
pub use web::{spawn, SpawnHandle};
// =======================
// Compile-time Assertions
// =======================
const _: () = {
fn assert_platform<T: PlatformInterface>() {}
// Check each implementation, and prevent warnings that the implementations are unused.
#[cfg(feature = "web")]
let _ = assert_platform::<web::WebPlatform>;
#[cfg(feature = "desktop")]
let _ = assert_platform::<desktop::DesktopPlatform>;
#[cfg(feature = "mobile")]
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()
}
#[cfg(any(feature = "desktop"))]
pub use desktop::*;
+45 -42
View File
@@ -1,12 +1,19 @@
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{eyre, Error};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
use futures::io::{AsyncRead, AsyncWrite};
use std::mem::replace;
use std::sync::Arc;
use std::sync::Mutex;
use tracing::{error, info, warn};
pub struct NativeAudioSystem {
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
pub struct AudioSystem {
output: cpal::Device,
input: cpal::Device,
processors: AudioProcessorSender,
@@ -15,8 +22,6 @@ pub struct NativeAudioSystem {
const SAMPLE_RATE: u32 = 48_000;
const PACKET_SAMPLES: u32 = 960;
// Divide by 1000 to get samples per ms, then multiply by 60ms for max Opus frame size.
const MAX_DECODE_SAMPLES: usize = SAMPLE_RATE as usize / 1000 * 60;
fn encode_and_send(
state: TransmitState,
@@ -45,7 +50,28 @@ fn encode_and_send(
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
impl NativeAudioSystem {
impl AudioSystem {
pub async fn new() -> Result<Self, Error> {
// TODO
let host = cpal::default_host();
let name = host.id();
let processors = AudioProcessorSender::default();
Ok(AudioSystem {
output: host
.default_output_device()
.ok_or(eyre!("no output devices from {name:?}"))?,
input: host
.default_input_device()
.ok_or(eyre!("no input devices from {name:?}"))?,
processors,
recording_stream: None,
})
}
pub fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
fn choose_config(
&self,
configs: impl Iterator<Item = cpal::SupportedStreamConfigRange>,
@@ -75,32 +101,8 @@ impl NativeAudioSystem {
.cloned()
.ok_or(eyre!("no supported stream configs"))
}
}
impl super::AudioSystemInterface for NativeAudioSystem {
type AudioPlayer = NativeAudioPlayer;
async fn new() -> Result<Self, Error> {
let host = cpal::default_host();
let name = host.id();
let processors = AudioProcessorSender::default();
Ok(NativeAudioSystem {
output: host
.default_output_device()
.ok_or(eyre!("no output devices from {name:?}"))?,
input: host
.default_input_device()
.ok_or(eyre!("no input devices from {name:?}"))?,
processors,
recording_stream: None,
})
}
fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
fn start_recording(
pub fn start_recording(
&mut self,
mut each: impl FnMut(Vec<u8>, bool) + Send + 'static,
) -> Result<(), Error> {
@@ -120,8 +122,7 @@ impl super::AudioSystemInterface for NativeAudioSystem {
if let Some(new_processor) = processors.take() {
current_processor = new_processor;
}
let state =
current_processor.process(frame, config.channels as usize, &mut output_buffer);
let state = current_processor.process(frame, config.channels as usize, &mut output_buffer);
encode_and_send(state, &mut output_buffer, &mut encoder, &mut each);
};
@@ -141,7 +142,7 @@ impl super::AudioSystemInterface for NativeAudioSystem {
}
}
fn create_player(&mut self) -> Result<NativeAudioPlayer, Error> {
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let config = self.choose_config(self.output.supported_output_configs()?)?;
info!(
"creating player on {:?} with {:#?}",
@@ -179,29 +180,31 @@ impl super::AudioSystemInterface for NativeAudioSystem {
)?
};
stream.play()?;
Ok(NativeAudioPlayer {
Ok(AudioPlayer {
decoder,
stream,
buffer,
tmp: vec![0; MAX_DECODE_SAMPLES],
tmp: vec![0; 2400],
})
}
}
pub struct NativeAudioPlayer {
pub struct AudioPlayer {
decoder: opus::Decoder,
stream: cpal::Stream,
buffer: Buffer,
tmp: Vec<i16>,
}
impl super::AudioPlayerInterface for NativeAudioPlayer {
fn play_opus(&mut self, payload: &[u8]) {
let len = match self.decoder.decode(payload, &mut self.tmp, false) {
Ok(l) => l,
Err(e) => {
error!("opus decode error {e:?}");
return;
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
let len = loop {
match self.decoder.decode(payload, &mut self.tmp, false) {
Ok(l) => break l,
Err(e) => {
error!("opus decode error {e:?}");
return;
}
}
};
-117
View File
@@ -1,117 +0,0 @@
use color_eyre::eyre::Error;
use std::collections::HashMap;
use tracing::info;
#[derive(Clone, PartialEq)]
pub struct NativeConfigSystem {
config_path: std::path::PathBuf,
}
impl super::ConfigSystemInterface for NativeConfigSystem {
fn new() -> color_eyre::Result<Self, Error> {
return Ok(NativeConfigSystem {
config_path: get_config_path()?,
});
}
fn config_get<T>(&self, key: &str) -> Option<T>
where
T: serde::de::DeserializeOwned,
{
let config = load_config_map(&self.config_path);
let Some(value_untyped) = config.get(key).cloned().or_else(|| config_get_default(key))
else {
return None;
};
match serde_json::from_value::<T>(value_untyped) {
Ok(v) => Some(v),
Err(_) => {
let default_value = config_get_default(key)
.expect("Default value required after config parse failure");
Some(
serde_json::from_value::<T>(default_value)
.expect("Default value could not be parsed"),
)
}
}
}
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize,
{
let mut config = load_config_map(&self.config_path);
let json_value = serde_json::to_value(value).expect("failed to serialize config value");
config.insert(key.to_string(), json_value);
save_config_map(&config).expect("failed to set config")
}
}
#[cfg(any(feature = "desktop"))]
fn get_config_path() -> color_eyre::Result<std::path::PathBuf> {
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "xyz".to_string(),
author: "ohea".to_string(),
app_name: "Mumble Web2".to_string(),
})
.expect("failed to choose app strategy");
Ok(strategy.config_dir().join("config.json"))
}
#[cfg(target_os = "android")]
fn get_config_path() -> color_eyre::Result<std::path::PathBuf> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }?;
let mut env = vm.attach_current_thread()?;
let ctx = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
let cache_dir = env
.call_method(ctx, "getFilesDir", "()Ljava/io/File;", &[])?
.l()?;
let cache_dir: jni::objects::JString = env
.call_method(&cache_dir, "toString", "()Ljava/lang/String;", &[])?
.l()?
.try_into()?;
let cache_dir = env.get_string(&cache_dir)?;
let cache_dir = cache_dir.to_str()?;
Ok(std::path::PathBuf::from(cache_dir).join("config.json"))
}
fn load_config_map(config_path: &std::path::PathBuf) -> HashMap<String, serde_json::Value> {
match std::fs::read_to_string(config_path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
fn save_config_map(config: &HashMap<String, serde_json::Value>) -> color_eyre::Result<()> {
let config_path = get_config_path().expect("Could not get config file path.");
if let Some(parent) = config_path.parent() {
info!("Creating config directory: {}", parent.display());
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(config)?;
info!("Writing config to {}", config_path.display());
std::fs::write(&config_path, contents)?;
Ok(())
}
fn config_get_default(key: &str) -> Option<serde_json::Value> {
let default_config = platform_default_config();
default_config
.get(key)
.cloned()
.or(super::global_default_config().get(key).cloned())
}
fn platform_default_config() -> HashMap<String, serde_json::Value> {
serde_json::json!({})
.as_object()
.unwrap()
.clone()
.into_iter()
.collect()
}
-128
View File
@@ -1,128 +0,0 @@
/// Stub implementation of the platform interface, so that we can
/// `cargo check` without any --feature flags.
use crate::{app::SharedState, effects::AudioProcessor};
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, 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")
}
fn request_permissions() {
panic!("stubbed platform")
}
fn network_connect(
_address: String,
_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,
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
async { panic!("stubbed platform") }
}
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>> {
async { panic!("stubbed platform") }
}
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
async { panic!("stubbed platform") }
}
}
pub struct StubAudioSystem;
impl super::AudioSystemInterface for StubAudioSystem {
type AudioPlayer = StubAudioPlayer;
async fn new() -> Result<Self, Error> {
panic!("stubbed platform")
}
fn set_processor(&self, _processor: AudioProcessor) {
panic!("stubbed platform")
}
fn start_recording(
&mut self,
_each: impl FnMut(Vec<u8>, bool) + Send + 'static,
) -> Result<(), Error> {
panic!("stubbed platform")
}
fn create_player(&mut self) -> Result<Self::AudioPlayer, Error> {
panic!("stubbed platform")
}
}
pub struct StubAudioPlayer;
impl super::AudioPlayerInterface for StubAudioPlayer {
fn play_opus(&mut self, _payload: &[u8]) {
panic!("stubbed platform")
}
}
#[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;
impl SpawnHandle {
#[allow(unused)]
pub fn spawn<F>(&self, _future: F)
where
F: Future<Output = ()> + 'static,
{
panic!("stubbed platform")
}
#[allow(unused)]
pub fn current() -> Self {
SpawnHandle
}
}
#[allow(unused)]
pub fn spawn<F>(_future: F)
where
F: Future<Output = ()> + 'static,
{
panic!("stubbed platform")
}
+105 -159
View File
@@ -1,14 +1,14 @@
use crate::app::{Command, SharedState};
use crate::app::Command;
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{bail, eyre, Error};
use crossbeam::atomic::AtomicCell;
use dioxus::prelude::*;
use futures::{AsyncRead, AsyncWrite};
use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array;
use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use mumble_web2_common::{ClientConfig, ServerStatus};
use reqwest::Url;
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
@@ -29,6 +29,7 @@ use web_sys::AudioWorkletNode;
use web_sys::EncodedAudioChunk;
use web_sys::EncodedAudioChunkInit;
use web_sys::EncodedAudioChunkType;
use web_sys::MediaStream;
use web_sys::MediaStreamConstraints;
use web_sys::MessageEvent;
use web_sys::WebTransport;
@@ -38,96 +39,16 @@ use web_sys::WorkletOptions;
use web_sys::{console, window};
use web_sys::{AudioContext, AudioDataCopyToOptions};
#[allow(unused)]
pub use wasm_bindgen_futures::spawn_local as spawn;
#[allow(unused)]
#[derive(Clone)]
pub struct SpawnHandle;
pub trait ImpRead: AsyncRead + Unpin + 'static {}
impl<T: AsyncRead + Unpin + 'static> ImpRead for T {}
impl SpawnHandle {
pub fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(future);
}
pub trait ImpWrite: AsyncWrite + Unpin + 'static {}
impl<T: AsyncWrite + Unpin + 'static> ImpWrite for T {}
pub fn current() -> Self {
SpawnHandle
}
}
/// Web platform implementation using WebTransport and Web Audio API.
pub struct WebPlatform;
impl super::PlatformInterface for WebPlatform {
type AudioSystem = WebAudioSystem;
type ConfigSystem = WebConfigSystem;
fn init_logging() {
// copied from tracing_web example usage
use tracing_subscriber::fmt::format::Pretty;
use tracing_subscriber::prelude::*;
use tracing_web::{performance_layer, MakeWebConsoleWriter};
let fmt_layer = tracing_subscriber::fmt::layer()
.with_ansi(false) // Only partially supported across browsers
.without_time() // std::time is not available in browsers
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
.with_filter(LevelFilter::DEBUG);
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
tracing_subscriber::registry()
.with(fmt_layer)
.with(perf_layer)
.init();
info!("logging initiated");
}
fn request_permissions() {
// No-op on web
}
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
let overrides = match option_env!("MUMBLE_WEB2_PROXY_OVERRIDES_URL") {
Some(url) => Url::parse(url)?,
None => absolute_url("overrides")?,
};
info!("loading config from {}", overrides);
let config = reqwest::get(overrides)
.await?
.json::<ProxyOverrides>()
.await?;
Ok(config)
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(absolute_url("status")?)
.send()
.await?
.json::<ServerStatus>()
.await?)
}
async fn sleep(duration: Duration) {
TimeoutFuture::new(duration.as_millis() as u32).await;
}
pub async fn sleep(d: Duration) {
TimeoutFuture::new(d.as_millis() as u32).await
}
trait ResultExt<T> {
@@ -152,7 +73,7 @@ impl<T> ResultExt<T> for Result<T, JsError> {
}
}
pub struct WebAudioSystem {
pub struct AudioSystem {
webctx: AudioContext,
processors: AudioProcessorSender,
}
@@ -183,10 +104,8 @@ async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
Ok(())
}
impl super::AudioSystemInterface for WebAudioSystem {
type AudioPlayer = WebAudioPlayer;
async fn new() -> Result<Self, Error> {
impl AudioSystem {
pub async fn new() -> Result<Self, Error> {
// Create MediaStreams to playback decoded audio
// The audio context is used to reproduce audio.
let webctx = configure_audio_context();
@@ -194,14 +113,17 @@ impl super::AudioSystemInterface for WebAudioSystem {
let processors = AudioProcessorSender::default();
Ok(WebAudioSystem { webctx, processors })
Ok(AudioSystem { webctx, processors })
}
fn set_processor(&self, processor: AudioProcessor) {
pub fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
fn start_recording(&mut self, each: impl FnMut(Vec<u8>, bool) + 'static) -> Result<(), Error> {
pub fn start_recording(
&mut self,
each: impl FnMut(Vec<u8>, bool) + 'static,
) -> Result<(), Error> {
let audio_context_worklet = self.webctx.clone();
let processors = self.processors.clone();
spawn(async move {
@@ -213,7 +135,7 @@ impl super::AudioSystemInterface for WebAudioSystem {
Ok(())
}
fn create_player(&mut self) -> Result<WebAudioPlayer, Error> {
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?;
// Connect worklet to destination
@@ -266,14 +188,14 @@ impl super::AudioSystemInterface for WebAudioSystem {
decoder_error.forget();
output.forget();
Ok(WebAudioPlayer(audio_decoder))
Ok(AudioPlayer(audio_decoder))
}
}
pub struct WebAudioPlayer(AudioDecoder);
pub struct AudioPlayer(AudioDecoder);
impl super::AudioPlayerInterface for WebAudioPlayer {
fn play_opus(&mut self, payload: &[u8]) {
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
let js_audio_payload = Uint8Array::from(payload);
let _ = self.0.decode(
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
@@ -434,8 +356,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");
@@ -448,7 +369,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()?;
}
@@ -494,7 +415,32 @@ pub async fn network_connect(
let writer =
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
crate::network_loop(username, state, event_rx, reader, writer).await
crate::network_loop(username, event_rx, reader, writer).await
}
pub fn set_default_username(username: &str) -> Option<()> {
web_sys::window()?
.local_storage()
.ok()??
.set_item("username", username)
.ok()
}
pub fn set_default_server(username: &str) -> Option<()> {
None
}
pub fn load_username() -> Option<String> {
web_sys::window()
.unwrap()
.local_storage()
.ok()??
.get_item("username")
.ok()?
}
pub fn load_server_url() -> Option<String> {
None
}
pub fn absolute_url(path: &str) -> Result<Url, Error> {
@@ -503,63 +449,63 @@ pub fn absolute_url(path: &str) -> Result<Url, Error> {
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
}
#[derive(Clone, PartialEq)]
pub struct WebConfigSystem {}
pub 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("config")?,
};
info!("loading config from {}", config_url);
impl super::ConfigSystemInterface for WebConfigSystem {
fn new() -> Result<Self, Error> {
return Ok(WebConfigSystem {});
let config = reqwest::get(config_url)
.await?
.json::<ClientConfig>()
.await?;
Ok(config)
}
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(absolute_url("status")?)
.send()
.await?
.json::<ServerStatus>()
.await?)
}
pub fn init_logging() {
// copied from tracing_web example usage
use tracing_subscriber::fmt::format::Pretty;
use tracing_subscriber::prelude::*;
use tracing_web::{performance_layer, MakeWebConsoleWriter};
let fmt_layer = tracing_subscriber::fmt::layer()
.with_ansi(false) // Only partially supported across browsers
.without_time() // std::time is not available in browsers
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
.with_filter(LevelFilter::DEBUG);
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
tracing_subscriber::registry()
.with(fmt_layer)
.with(perf_layer)
.init();
info!("logging initiated");
}
pub struct SpawnHandle;
impl SpawnHandle {
pub fn current() -> Self {
SpawnHandle
}
fn config_get<T>(&self, key: &str) -> Option<T>
pub fn spawn<F>(&self, future: F)
where
T: serde::de::DeserializeOwned,
F: Future<Output = ()> + 'static,
{
// 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");
spawn(future);
}
}
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()
}
+63 -53
View File
@@ -1,17 +1,17 @@
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::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::UnboundedSender;
pub use imp::spawn;
use msghtml::process_message_html;
use mumble_protocol::control::msgs;
use mumble_protocol::control::ControlCodec;
@@ -26,21 +26,15 @@ use std::time::Duration;
use tracing::error;
use tracing::info;
use crate::app::AudioSettings;
use crate::app::SharedState;
use crate::app::State;
use crate::effects::AudioProcessor;
use crate::imp::{
AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
PlatformInterface as _,
};
use crate::imp::AudioSystem;
pub mod app;
mod effects;
pub mod imp;
mod msghtml;
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>, state: SharedState) {
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
loop {
let Some(Command::Connect {
address,
@@ -51,29 +45,23 @@ 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;
if let Err(error) =
Platform::network_connect(address, username, &mut event_rx, &config, state.clone())
.await
{
*STATE.server.write() = Default::default();
*STATE.status.write() = ConnectionState::Connecting;
if let Err(error) = imp::network_connect(address, username, &mut event_rx, &config).await {
error!("could not connect {:?}", error);
*state.status.write_unchecked() = ConnectionState::Failed(error.to_string());
*STATE.status.write() = ConnectionState::Failed(error.to_string());
} else {
*state.status.write_unchecked() = ConnectionState::Disconnected;
*STATE.status.write() = ConnectionState::Disconnected;
}
}
}
pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin + 'static>(
pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
username: String,
state: SharedState,
event_rx: &mut UnboundedReceiver<Command>,
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 {
@@ -117,19 +105,16 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
break;
}
Platform::sleep(Duration::from_millis(3000)).await;
imp::sleep(Duration::from_millis(3000)).await;
}
});
}
let mut audio = AudioSystem::new().await?;
if audio_settings.denoise {
audio.set_processor(AudioProcessor::new_denoising());
}
let mut audio = imp::AudioSystem::new().await?;
{
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,
@@ -140,9 +125,7 @@ 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
@@ -160,7 +143,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, &state);
let res = accept_packet(msg, &mut audio, &mut decoder_map);
if let Err(err) = res {
error!("error accepting packet {:?}", err)
}
@@ -179,7 +162,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, &state);
let res = accept_command(command, &mut send_chan, &mut audio);
if let Err(err) = res {
info!("error accepting command {:?}", err)
}
@@ -198,10 +181,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")
};
@@ -224,7 +206,7 @@ fn accept_command(
};
{
let mut server = state.server.write_unchecked();
let mut server = STATE.server.write();
let Some(me) = server.session else {
bail!("not signed in with a session id")
};
@@ -265,7 +247,7 @@ fn accept_command(
};
{
let mut server = state.server.write_unchecked();
let mut server = STATE.server.write();
let Some(me) = server.session else {
bail!("not signed in with a session id")
};
@@ -300,7 +282,7 @@ fn accept_command(
let _ = send_chan.unbounded_send(u.into());
}
Connect { .. } | Disconnect => (),
UpdateAudioSettings(AudioSettings { denoise }) => {
UpdateMicEffects { denoise } => {
if denoise {
audio.set_processor(AudioProcessor::new_denoising());
} else {
@@ -314,9 +296,8 @@ fn accept_command(
fn accept_packet(
msg: ControlPacket<mumble_protocol::Clientbound>,
audio_context: &mut AudioSystem,
player_map: &mut HashMap<u32, AudioPlayer>,
state: &State,
audio_context: &mut imp::AudioSystem,
player_map: &mut HashMap<u32, imp::AudioPlayer>,
) -> Result<(), Error> {
match msg {
ControlPacket::UDPTunnel(u) => {
@@ -353,15 +334,45 @@ fn accept_packet(
}
}
ControlPacket::ChannelState(u) => {
let mut server = state.server.write_unchecked();
server.channels_state.update_from_channel_state(&u);
let mut server = STATE.server.write();
let id = u.get_channel_id();
let state = server.channels.entry(id).or_default();
let new_parent = if u.has_parent() {
if let Some(parent) = state.parent.and_then(|p| server.channels.get_mut(&p)) {
parent.children.remove(&id);
}
let parent_id = u.get_parent();
let parent = server.channels.entry(parent_id).or_default();
if u.has_position() && u.get_position() as usize <= parent.children.len() {
// TODO: what if positions are received out of order? we need to sort afterwards?
parent.children.insert_before(u.get_position() as usize, id);
} else {
parent.children.insert(id);
}
Some(parent_id)
} else {
None
};
let state = server.channels.entry(id).or_default();
state.parent = new_parent;
if u.has_name() {
state.name = u.get_name().to_string();
}
}
ControlPacket::ChannelRemove(u) => {
let mut server = state.server.write_unchecked();
server.channels_state.update_from_channel_remove(&u);
let mut server = STATE.server.write();
let id = u.get_channel_id();
if let Some(channel) = server.channels.remove(&id) {
if let Some(parent) = channel.parent.and_then(|p| server.channels.get_mut(&p)) {
parent.children.remove(&id);
}
}
}
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();
@@ -370,13 +381,12 @@ fn accept_packet(
let state = state_entry.or_default();
// the server might now send a channel_id if the user is in channel=0
if u.has_channel_id() || new {
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
if let Some(parent) = server.channels.get_mut(&state.channel) {
parent.users.remove(&id);
}
let channel_id = u.get_channel_id();
server
.channels_state
.channels
.entry(channel_id)
.or_default()
@@ -405,16 +415,16 @@ 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) {
if let Some(parent) = server.channels.get_mut(&state.channel) {
parent.users.remove(&id);
}
}
}
ControlPacket::TextMessage(u) => {
let mut server = state.server.write_unchecked();
let mut server = STATE.server.write();
if u.has_message() {
let text = u.get_message().to_string();
server.chat.push(Chat {
@@ -429,8 +439,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 {
+3 -19
View File
@@ -1,22 +1,6 @@
use dioxus::prelude::*;
use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
use mumble_web2_gui::{app, imp};
pub fn main() {
Platform::init_logging();
dioxus::LaunchBuilder::new()
.with_cfg(desktop! {
dioxus::desktop::Config::new()
// Reduce white flash on startup by setting background color and hiding main element
.with_background_color((0, 0, 0, 255))
.with_custom_head("<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::app);
imp::init_logging();
dioxus::launch(app::app);
}
+14 -11
View File
@@ -1,5 +1,5 @@
use color_eyre::eyre::{anyhow, bail, Context, Result};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use mumble_web2_common::{ClientConfig, ServerStatus};
use rand::Rng;
use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::cors::{AllowOrigin, Cors};
@@ -16,7 +16,7 @@ use tokio::net::TcpStream;
use tokio::pin;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct};
use tokio_rustls::rustls::{ClientConfig as RlsClientConfig, DigitallySignedStruct};
use tokio_rustls::{rustls, TlsConnector};
use tracing::info;
use tracing::info_span;
@@ -77,7 +77,7 @@ async fn main() -> Result<()> {
.install_default()
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?;
let mut overrides = ProxyOverrides {
let mut client_config = ClientConfig {
proxy_url: match &server_config.proxy_url {
Some(url) => Some(url.to_string()),
None => None,
@@ -102,7 +102,7 @@ async fn main() -> Result<()> {
let cert = cert_params.self_signed(&key_pair)?;
let hash = hmac_sha256::Hash::hash(cert.der().as_ref());
overrides.cert_hash = Some(hash.into());
client_config.cert_hash = Some(hash.into());
(cert.pem().into(), key_pair.serialize_pem().into())
}
@@ -122,11 +122,14 @@ async fn main() -> Result<()> {
};
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
info!("proxy overrides:\n{}", toml::to_string_pretty(&overrides)?);
info!(
"client config:\n{}",
toml::to_string_pretty(&client_config)?
);
let config_craft = ConfigCraft {
server_config: server_config.clone(),
overrides,
client_config,
};
let status_craft = StatusCraft {
@@ -136,7 +139,7 @@ async fn main() -> Result<()> {
// Server routing
let mut router = Router::new()
.push(Router::with_path("/proxy").goal(config_craft.connect_proxy()))
.push(Router::with_path("/overrides").get(config_craft.get_overrides()))
.push(Router::with_path("/config").get(config_craft.get_config()))
.push(Router::with_path("/status").get(status_craft.get_status()))
.hoop(Logger::new());
if let Some(gui_path) = server_config.gui_path.clone() {
@@ -249,14 +252,14 @@ impl StatusCraft {
#[derive(Clone)]
pub struct ConfigCraft {
server_config: Arc<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)]
@@ -317,7 +320,7 @@ async fn connect_proxy_impl(
) -> Result<()> {
info!("connecting to Mumble server...");
let config = ClientConfig::builder()
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();