8 Commits

Author SHA1 Message Date
restitux da76d9c259 build: add script to build android locally
Build Mumble Web 2 / macos_build (push) Successful in 53s
Build Mumble Web 2 / windows_build (push) Successful in 3m2s
Build Mumble Web 2 / linux_build (push) Successful in 1m15s
Build Mumble Web 2 / android_build (push) Successful in 4m32s
2026-05-04 21:32:54 -06: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
restitux d67a19c478 Create new generic config abstraction (#25)
Build Mumble Web 2 / macos_build (push) Successful in 55s
Build Mumble Web 2 / linux_build (push) Successful in 1m18s
Build Mumble Web 2 / android_build (push) Successful in 5m36s
Build Mumble Web 2 / windows_build (push) Successful in 8m4s
Build android container / android-release-builder-container-build (push) Successful in 7s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
This change migrates the config logic to a new generic key+value abstraction. This allows config parameters to be get and set with arbitrary string keys. Config value types can be anything that serde knows how to serialize / deserialize.

Implementations:
Desktop:
Uses a json file in a platform specific directory (pulled from etcetera). This is mostly the same as the existing code. Implemented in `native_config.rs`
Android:
Uses the same mechanism as desktop, with a different path selection that calls out to the android apis (via jni) to get the correct directory.
Web:
Uses browser local storage. Values are stored as strings instead of actual json objects to keep things simple for now. We might want to update this at some point.

Desktop support:
![2026-03-04-223906_grim.png](/attachments/18bfea3e-456c-40f3-9b14-f865c062fcb0)
```
% cat ~/.config/mumble-web2/config.json
{
  "username": "restitux-test",
  "server_url": "voip.ohea.xyz"
}%
```
Web support:
![2026-03-04-223243_grim.png](/attachments/fc55c0c0-1422-4ae8-8e43-9829c6ab7920)
Android support:
![image.png](/attachments/28d9c0f1-ef87-4561-83db-9e4916208267)
```
root@c053bdd1b4da:/# adb shell
tokay:/ $ run-as xyz.ohea.mumble_web_2
tokay:/data/user/0/xyz.ohea.mumble_web_2 $ ls
app_textures  app_webview  cache  code_cache  files  no_backup  shared_prefs
tokay:/data/user/0/xyz.ohea.mumble_web_2 $ ls files
config.json  oat  permission_manager.dex
tokay:/data/user/0/xyz.ohea.mumble_web_2 $ cat files/config.json
{
  "server_url": "voip.ohea.xyz",
  "username": "test"
}tokay:/data/user/0/xyz.ohea.mumble_web_2 $
```

Reviewed-on: #25
Reviewed-by: Sam Sartor <cap@samsartor.com>
2026-03-11 04:02:22 +00:00
restitux 518c50d8a4 Add builds and CI for MacOS (#26)
Build Mumble Web 2 / macos_build (push) Successful in 58s
Build Mumble Web 2 / windows_build (push) Successful in 3m6s
Build Mumble Web 2 / linux_build (push) Successful in 1m9s
Build Mumble Web 2 / android_build (push) Successful in 5m41s
Build android container / android-release-builder-container-build (push) Successful in -8s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 15s
This change adds CI to build the desktop client for MacOS. This builds the desktop client as a dmg and a .app and uploads them from the CI pipeline.

Reviewed-on: #26
Reviewed-by: Sam Sartor <cap@samsartor.com>
2026-03-11 03:26:41 +00:00
sam 847c636f41 rename gui_config to proxy_overrides (#23)
Build Mumble Web 2 / linux_build (push) Successful in 1m13s
Build Mumble Web 2 / windows_build (push) Successful in 2m39s
Build Mumble Web 2 / android_build (push) Successful in 6m18s
Build android container / android-release-builder-container-build (push) Successful in -7s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 30m8s
Reviewed-on: #23
Reviewed-by: restitux <restitux@ohea.xyz>
Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2026-03-05 07:16:02 +00:00
sam 9006a082b0 Refactor the imp/gui bondary to use real traits (#18)
Build Mumble Web 2 / linux_build (push) Successful in 1m24s
Build Mumble Web 2 / windows_build (push) Successful in 2m36s
Build Mumble Web 2 / android_build (push) Successful in 5m57s
Build android container / android-release-builder-container-build (push) Successful in -4s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 16s
# Summary

Introduces a trait-based platform abstraction layer that makes the boundary
between platform-specific and shared code explicit and compile-time verified.

The TLDR version of this new trait stuff works:

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

```rust
#[cfg(feature = "web")]
pub type Platform = web::WebPlatform;

#[cfg(all(feature = "desktop"))]
pub type Platform = desktop::DesktopPlatform;

#[cfg(all(feature = "mobile", not(feature = "web")))]
pub type Platform = mobile::MobilePlatform;
```

5. Add a compile time assertion that `Platform` implements `PlatformInterface`.

#  Motivation

Previously, platform code used a mix of pub use re-exports and #[cfg] blocks
that made it difficult to understand what each platform must implement. The
new trait-based approach provides:

- Clear documentation of the platform contract
- Compile-time verification that all platforms implement required
  functionality
- Ability to cargo check without feature flags (via stub platform)

# Changes

New traits in imp/mod.rs:
  - PlatformInterface - logging, permissions, network, config, storage. Overall this the trait that platforms must satify to compile.
  - AudioSystemInterface - audio system initialization and recording
  - AudioPlayerInterface - opus audio playback

 Type aliases:
  - Platform, AudioSystem, AudioPlayer resolve to the correct types based on
  feature flags

Call site updates:
  - Changed from imp::function() to Platform::function() syntax
  - Removed ImpRead/ImpWrite helper traits in favor of direct bounds

# Testing

Manual testing reveals that Web and Desktop still work, I (Liam) have not tested the mobile version beyond compilation.

Co-authored-by: Liam Warfield <liam.warfield@gmail.com>
Reviewed-on: #18
Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2026-02-18 04:53:41 +00:00
19 changed files with 621 additions and 272 deletions
+1
View File
@@ -0,0 +1 @@
target
+41
View File
@@ -42,6 +42,47 @@ 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 ClientConfig {
pub struct ProxyOverrides {
pub proxy_url: Option<String>,
pub cert_hash: Option<Vec<u8>>,
pub any_server: bool,
+7 -9
View File
@@ -1,14 +1,12 @@
localhost:64444 {
tls internal
tls internal
# Proxy /config path to mumble-web2-proxy
reverse_proxy /config http://127.0.0.1:4400
# Proxy /config path to mumble-web2-proxy
reverse_proxy /overrides 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 /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
}
+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"
+1 -1
View File
@@ -20,7 +20,7 @@ services:
# volumes:
# - ..:/app
# environment:
# - MUMBLE_WEB2_GUI_CONFIG_URL=https://localhost:64444/config
# - MUMBLE_WEB2_PROXY_OVERRIDES_URL=https://localhost:64444/overrides
# stdin_open: true
# tty: true
# command: >
+1
View File
@@ -16,6 +16,7 @@ body {
}
#main {
visibility: visible;
height: 100vh;
display: flex;
flex-direction: column;
+105 -58
View File
@@ -2,15 +2,17 @@
use dioxus::prelude::*;
use mime_guess::Mime;
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use ordermap::OrderSet;
use std::collections::{HashMap, HashSet};
use std::{fmt, sync::Arc};
use crate::imp::{Platform, PlatformInterface as _};
use crate::imp::{ConfigSystem, ConfigSystemInterface as _, Platform, PlatformInterface as _};
pub type ChannelId = u32;
pub type UserId = u32;
#[derive(Debug)]
pub enum ConnectionState {
Disconnected,
Connecting,
@@ -18,12 +20,17 @@ 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: ClientConfig,
config: ProxyOverrides,
},
SendChat {
markdown: String,
@@ -45,16 +52,14 @@ pub enum Command {
channel: ChannelId,
user: UserId,
},
UpdateMicEffects {
denoise: bool,
},
UpdateAudioSettings(AudioSettings),
Disconnect,
}
use Command::*;
use ConnectionState::*;
#[derive(Default)]
#[derive(Default, Debug)]
pub struct UserState {
pub name: String,
pub channel: ChannelId,
@@ -79,13 +84,14 @@ impl UserState {
}
}
#[derive(Debug)]
pub struct Chat {
pub raw: String,
pub dangerous_html: String,
pub sender: Option<UserId>,
}
#[derive(Default)]
#[derive(Default, Debug)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
@@ -111,7 +117,7 @@ impl ChannelState {
}
}
#[derive(Default)]
#[derive(Default, Debug)]
pub struct ChannelsState {
pub channels: HashMap<ChannelId, ChannelState>,
}
@@ -198,7 +204,7 @@ impl ChannelsState {
}
}
#[derive(Default)]
#[derive(Default, Debug)]
pub struct ServerState {
pub channels_state: ChannelsState,
pub users: HashMap<UserId, UserState>,
@@ -213,14 +219,21 @@ impl ServerState {
}
pub struct State {
pub status: GlobalSignal<ConnectionState>,
pub server: GlobalSignal<ServerState>,
pub status: Signal<ConnectionState>,
pub server: Signal<ServerState>,
pub audio: Signal<AudioSettings>,
}
pub static STATE: State = State {
status: Signal::global(|| Disconnected),
server: Signal::global(|| Default::default()),
};
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>;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum UserIcon {
@@ -267,7 +280,8 @@ pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
#[component]
pub fn User(id: UserId) -> Element {
let server = STATE.server.read();
let state = use_context::<SharedState>();
let server = state.server.read();
match server.users.get(&id) {
Some(state) => rsx!(UserPill {
name: state.name.clone(),
@@ -285,7 +299,8 @@ pub fn User(id: UserId) -> Element {
#[component]
pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
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}");
@@ -354,7 +369,8 @@ pub fn Channel(id: ChannelId) -> Element {
#[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() {
let state = use_context::<SharedState>();
let channels = if let Some(user) = state.server.read().this_user() {
vec![user.channel]
} else {
return;
@@ -380,11 +396,14 @@ 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 state = use_context::<SharedState>();
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() {
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],
@@ -454,10 +473,12 @@ pub fn ChatView() -> Element {
}
#[component]
pub fn ControlView(config: Resource<ClientConfig>) -> Element {
pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let status = &STATE.status;
let server = STATE.server.read();
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,
@@ -474,10 +495,10 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
let current_channel_name = server.channels_state.channels[&channel].name.clone();
let proxy_url = config
let proxy_url = overrides
.read_unchecked()
.as_ref()
.and_then(|gui_config| gui_config.proxy_url.clone());
.and_then(|overrides| overrides.proxy_url.clone());
let connecting_color = "yellow";
let connected_color = "oklch(0.55 0.1184 141.35)";
@@ -555,7 +576,6 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
},
};
let denoise = use_signal(|| false);
rsx!(
// Server control
div {
@@ -596,18 +616,23 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
}
span { class: "spacer" }
button {
class: match denoise() {
class: match audio.denoise {
true => "toggle_button is_on",
false => "toggle_button",
},
role: "switch",
aria_checked: denoise(),
aria_checked: audio.denoise,
onclick: move |_| {
let new_denoise = !denoise();
*denoise.write_unchecked() = new_denoise;
net.send(UpdateMicEffects { denoise: new_denoise })
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 denoise() {
match audio.denoise {
true => rsx!(span { class: "material-symbols-outlined", "cadence"}),
false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}),
}
@@ -645,9 +670,10 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
}
#[component]
pub fn ServerView(config: Resource<ClientConfig>) -> Element {
pub fn ServerView(overrides: Resource<ProxyOverrides>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let state = use_context::<SharedState>();
let server = state.server.read();
let Some(&UserState {
deaf,
self_deaf,
@@ -676,14 +702,15 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
}
div {
class: "server_control_box",
ControlView { config }
ControlView { overrides }
}
}
)
}
#[component]
pub fn LoginView(config: Resource<ClientConfig>) -> Element {
pub fn LoginView(overrides: Resource<ProxyOverrides>) -> Element {
let user_config = use_context::<ConfigSystem>();
let net: Coroutine<Command> = use_coroutine_handle();
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
@@ -695,33 +722,36 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
}
});
let mut address_input = use_signal(|| Platform::load_server_url());
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 {
config()
overrides()
.and_then(|c| c.proxy_url.clone())
.unwrap_or_default()
}
});
let previous_username = Platform::load_username();
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
let mut username = use_signal(|| {
user_config
.config_get::<String>("username")
.unwrap_or(String::new())
});
let do_connect = move |_| {
//let _ = set_default_username(&username.read());
let _ = Platform::set_default_username(&username.read());
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
Platform::set_default_server(&address.read());
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: config.read().clone().unwrap_or_default(),
config: overrides.read().clone().unwrap_or_default(),
})
};
let status = &STATE.status;
let state = use_context::<SharedState>();
let status = &state.status;
let bottom = match &*status.read() {
Disconnected => rsx! {
button {
@@ -763,7 +793,7 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
None => rsx!(),
}
}
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) {
div {
label {
for: "address-entry",
@@ -855,27 +885,44 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
// )
}
#[component]
pub fn app() -> Element {
static STYLE: Asset = asset!("/assets/main.scss");
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
let config = use_resource(|| async move {
match Platform::load_config().await {
Ok(config) => config,
Err(_) => ClientConfig::default(),
}
use_effect(|| {
Platform::request_permissions();
});
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(),
}
});
rsx!(
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" }
document::Link{ rel: "stylesheet", href: STYLE }
match *STATE.status.read() {
Connected => rsx!(ServerView { config }),
_ => rsx!(LoginView { config }),
match *state.status.read() {
Connected => rsx!(ServerView { overrides }),
_ => rsx!(LoginView { overrides }),
}
)
}
+91
View File
@@ -0,0 +1,91 @@
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();
}
}
+7 -6
View File
@@ -1,4 +1,4 @@
use crate::app::Command;
use crate::app::{Command, SharedState};
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 as RlsClientConfig;
use tokio_rustls::rustls::ClientConfig;
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::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
#[derive(Debug)]
struct NoCertificateVerification;
@@ -73,11 +73,12 @@ pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
info!("connecting");
let config = RlsClientConfig::builder()
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
@@ -102,7 +103,7 @@ pub async fn network_connect(
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
crate::network_loop(username, event_rx, reader, writer).await
crate::network_loop(username, state, event_rx, reader, writer).await
}
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
+8 -58
View File
@@ -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 mumble_web2_common::{ClientConfig, ServerStatus};
use std::collections::HashMap;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::time::Duration;
/// Desktop platform implementation using Tokio and native audio.
@@ -11,48 +9,28 @@ pub struct DesktopPlatform;
impl super::PlatformInterface for DesktopPlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
type ConfigSystem = super::native_config::NativeConfigSystem;
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
Ok(ProxyOverrides {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
fn load_username() -> Option<String> {
let config = load_config_map();
config.get("username").cloned()
}
fn load_server_url() -> Option<String> {
let config = load_config_map();
config.get("server").cloned()
}
fn set_default_username(username: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("username".to_string(), username.to_string());
save_config_map(&config).ok()
}
fn set_default_server(server: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("server".to_string(), server.to_string());
save_config_map(&config).ok()
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, gui_config).await
super::connect::network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
@@ -78,31 +56,3 @@ impl super::PlatformInterface for DesktopPlatform {
// No-op on desktop
}
}
fn get_config_path() -> std::path::PathBuf {
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "com".to_string(),
author: "Ohea Corp".to_string(),
app_name: "Mumble Web2".to_string(),
})
.expect("failed to choose app strategy");
strategy.config_dir().join("config.json")
}
fn load_config_map() -> HashMap<String, String> {
let config_path = get_config_path();
match std::fs::read_to_string(&config_path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> {
let config_path = get_config_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(config)?;
std::fs::write(&config_path, contents)?;
Ok(())
}
+8 -23
View File
@@ -1,8 +1,7 @@
use crate::app::Command;
use crate::app::{Command, SharedState};
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::future::Future;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::time::Duration;
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
@@ -10,38 +9,24 @@ pub struct MobilePlatform;
impl super::PlatformInterface for MobilePlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
type ConfigSystem = super::native_config::NativeConfigSystem;
async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
Ok(ProxyOverrides {
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,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, gui_config).await
super::connect::network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
+42 -21
View File
@@ -4,10 +4,12 @@
//! 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 mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::collections::HashMap;
use std::future::Future;
use std::time::Duration;
@@ -50,11 +52,24 @@ pub trait AudioPlayerInterface {
fn play_opus(&mut self, payload: &[u8]);
}
pub trait ConfigSystemInterface: Sized + Clone {
fn new() -> Result<Self, Error>;
fn config_get<T>(&self, key: &str) -> Option<T>
where
T: serde::de::DeserializeOwned;
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize;
}
/// This is the main trait that each platform must implement. It combines all
/// platform-specific functionality into a single interface, providing compile-time
/// verification that all platforms implement the required functionality.
pub trait PlatformInterface {
type AudioSystem: AudioSystemInterface;
type ConfigSystem: ConfigSystemInterface;
/// Initialize logging for the platform.
fn init_logging();
@@ -67,7 +82,8 @@ pub trait PlatformInterface {
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
proxy_overrides: &ProxyOverrides,
state: SharedState,
) -> impl Future<Output = Result<(), Error>>;
/// Get server status (user count, version, etc.).
@@ -76,19 +92,7 @@ pub trait PlatformInterface {
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
/// Load the proxy overrides (proxy URL, cert hash, etc.).
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>>;
/// Load saved username.
fn load_username() -> Option<String>;
/// Load saved server URL.
fn load_server_url() -> Option<String>;
/// Save the default username.
fn set_default_username(username: &str) -> Option<()>;
/// Save the default server URL.
fn set_default_server(server: &str) -> Option<()>;
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>>;
/// Async sleep for the given duration.
fn sleep(duration: Duration) -> impl Future<Output = ()>;
@@ -98,15 +102,21 @@ pub trait PlatformInterface {
// Platform Modules
// ============================================================================
mod stub;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod connect;
#[cfg(feature = "desktop")]
mod desktop;
#[cfg(feature = "mobile")]
mod mobile;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
mod stub;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_config;
#[cfg(feature = "desktop")]
mod desktop;
#[cfg(feature = "mobile")]
mod mobile;
#[cfg(feature = "web")]
mod web;
@@ -133,6 +143,8 @@ 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
// ========================
@@ -164,3 +176,12 @@ const _: () = {
let _ = assert_platform::<mobile::MobilePlatform>;
let _ = assert_platform::<stub::StubPlatform>;
};
fn global_default_config() -> HashMap<String, serde_json::Value> {
serde_json::json!({})
.as_object()
.unwrap()
.clone()
.into_iter()
.collect()
}
+117
View File
@@ -0,0 +1,117 @@
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()
}
+29 -20
View File
@@ -1,15 +1,16 @@
/// 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 mumble_web2_common::{ClientConfig, ServerStatus};
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")
@@ -23,7 +24,8 @@ impl super::PlatformInterface for StubPlatform {
_address: String,
_username: String,
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
_gui_config: &ClientConfig,
_overrides: &ProxyOverrides,
_state: SharedState,
) -> impl Future<Output = Result<(), Error>> {
async { panic!("stubbed platform") }
}
@@ -34,26 +36,10 @@ impl super::PlatformInterface for StubPlatform {
async { panic!("stubbed platform") }
}
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>> {
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>> {
async { panic!("stubbed platform") }
}
fn load_username() -> Option<String> {
panic!("stubbed platform")
}
fn load_server_url() -> Option<String> {
panic!("stubbed platform")
}
fn set_default_username(_username: &str) -> Option<()> {
panic!("stubbed platform")
}
fn set_default_server(_server: &str) -> Option<()> {
panic!("stubbed platform")
}
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
async { panic!("stubbed platform") }
}
@@ -92,6 +78,29 @@ impl super::AudioPlayerInterface for StubAudioPlayer {
}
}
#[derive(Clone)]
pub struct StubConfigSystem;
impl super::ConfigSystemInterface for StubConfigSystem {
fn new() -> Result<Self, Error> {
panic!("stubbed platform")
}
fn config_get<T>(&self, key: &str) -> Option<T>
where
T: serde::de::DeserializeOwned,
{
panic!("stubbed platform")
}
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize,
{
panic!("stubbed platform")
}
}
#[allow(unused)]
pub struct SpawnHandle;
+78 -38
View File
@@ -1,4 +1,4 @@
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;
@@ -6,8 +6,9 @@ use dioxus::prelude::*;
use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array;
use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use reqwest::Url;
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
@@ -62,6 +63,7 @@ pub struct WebPlatform;
impl super::PlatformInterface for WebPlatform {
type AudioSystem = WebAudioSystem;
type ConfigSystem = WebConfigSystem;
fn init_logging() {
// copied from tracing_web example usage
@@ -89,53 +91,29 @@ impl super::PlatformInterface for WebPlatform {
// No-op on web
}
async fn load_config() -> color_eyre::Result<ClientConfig> {
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
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("config")?,
None => absolute_url("overrides")?,
};
info!("loading config from {}", config_url);
info!("loading config from {}", overrides);
let config = reqwest::get(config_url)
let config = reqwest::get(overrides)
.await?
.json::<ClientConfig>()
.json::<ProxyOverrides>()
.await?;
Ok(config)
}
fn load_username() -> Option<String> {
web_sys::window()
.unwrap()
.local_storage()
.ok()??
.get_item("username")
.ok()?
}
fn load_server_url() -> Option<String> {
None
}
fn set_default_username(username: &str) -> Option<()> {
web_sys::window()?
.local_storage()
.ok()??
.set_item("username", username)
.ok()
}
fn set_default_server(_server: &str) -> Option<()> {
None
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
network_connect(address, username, event_rx, gui_config).await
network_connect(address, username, event_rx, overrides, state).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
@@ -456,7 +434,8 @@ pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
overrides: &ProxyOverrides,
state: SharedState,
) -> Result<(), Error> {
info!("connecting");
@@ -469,7 +448,7 @@ pub async fn network_connect(
)
.ey()?;
if let Some(server_hash) = &gui_config.cert_hash {
if let Some(server_hash) = &overrides.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()?;
}
@@ -515,7 +494,7 @@ 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
crate::network_loop(username, state, event_rx, reader, writer).await
}
pub fn absolute_url(path: &str) -> Result<Url, Error> {
@@ -523,3 +502,64 @@ pub fn absolute_url(path: &str) -> Result<Url, Error> {
let location = window.location();
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
}
#[derive(Clone, PartialEq)]
pub struct WebConfigSystem {}
impl super::ConfigSystemInterface for WebConfigSystem {
fn new() -> Result<Self, Error> {
return Ok(WebConfigSystem {});
}
fn config_get<T>(&self, key: &str) -> Option<T>
where
T: serde::de::DeserializeOwned,
{
// Get Storage
let storage = web_sys::window()?.local_storage().ok()??;
// Try localStorage first
if let Ok(Some(raw)) = storage.get_item(key) {
if let Ok(parsed) = serde_json::from_str::<T>(&raw) {
return Some(parsed);
}
}
// Fallback to default if deserialization fails or key missing
let default_value = config_get_default(key)?;
serde_json::from_value::<T>(default_value).ok()
}
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize,
{
let storage = window()
.and_then(|w| w.local_storage().ok().flatten())
.expect("localStorage not available");
let json_value =
serde_json::to_string(value).expect("failed to serialize config value to JSON string");
storage
.set_item(key, &json_value)
.expect("failed to write to localStorage");
}
}
fn config_get_default(key: &str) -> Option<serde_json::Value> {
let default_config = platform_default_config();
default_config
.get(key)
.cloned()
.or(super::global_default_config().get(key).cloned())
}
fn platform_default_config() -> HashMap<String, serde_json::Value> {
serde_json::json!({})
.as_object()
.unwrap()
.clone()
.into_iter()
.collect()
}
+35 -22
View File
@@ -1,7 +1,6 @@
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};
@@ -27,6 +26,9 @@ 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,
@@ -38,7 +40,7 @@ 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,25 +51,29 @@ 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,
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,10 +123,13 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
}
let mut audio = AudioSystem::new().await?;
if audio_settings.denoise {
audio.set_processor(AudioProcessor::new_denoising());
}
{
let send_chan = send_chan.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 +140,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 +160,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 +179,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 send_chan, &mut audio, &state);
if let Err(err) = res {
info!("error accepting command {:?}", err)
}
@@ -187,9 +198,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 +224,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 +265,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,7 +300,7 @@ fn accept_command(
let _ = send_chan.unbounded_send(u.into());
}
Connect { .. } | Disconnect => (),
UpdateMicEffects { denoise } => {
UpdateAudioSettings(AudioSettings { denoise }) => {
if denoise {
audio.set_processor(AudioProcessor::new_denoising());
} else {
@@ -304,6 +316,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 +353,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 +405,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 +414,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 +429,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 {
+17 -1
View File
@@ -1,6 +1,22 @@
use dioxus::prelude::*;
use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
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::app);
}
+11 -14
View File
@@ -1,5 +1,5 @@
use color_eyre::eyre::{anyhow, bail, Context, Result};
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, 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 as RlsClientConfig, DigitallySignedStruct};
use tokio_rustls::rustls::{ClientConfig, 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 client_config = ClientConfig {
let mut overrides = ProxyOverrides {
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());
client_config.cert_hash = Some(hash.into());
overrides.cert_hash = Some(hash.into());
(cert.pem().into(), key_pair.serialize_pem().into())
}
@@ -122,14 +122,11 @@ async fn main() -> Result<()> {
};
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
info!(
"client config:\n{}",
toml::to_string_pretty(&client_config)?
);
info!("proxy overrides:\n{}", toml::to_string_pretty(&overrides)?);
let config_craft = ConfigCraft {
server_config: server_config.clone(),
client_config,
overrides,
};
let status_craft = StatusCraft {
@@ -139,7 +136,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("/config").get(config_craft.get_config()))
.push(Router::with_path("/overrides").get(config_craft.get_overrides()))
.push(Router::with_path("/status").get(status_craft.get_status()))
.hoop(Logger::new());
if let Some(gui_path) = server_config.gui_path.clone() {
@@ -252,14 +249,14 @@ impl StatusCraft {
#[derive(Clone)]
pub struct ConfigCraft {
server_config: Arc<Config>,
client_config: ClientConfig,
overrides: ProxyOverrides,
}
#[craft]
impl ConfigCraft {
#[craft(handler)]
async fn get_config(&self) -> Json<ClientConfig> {
Json(self.client_config.clone())
async fn get_overrides(&self) -> Json<ProxyOverrides> {
Json(self.overrides.clone())
}
#[craft(handler)]
@@ -320,7 +317,7 @@ async fn connect_proxy_impl(
) -> Result<()> {
info!("connecting to Mumble server...");
let config = RlsClientConfig::builder()
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();