1 Commits

Author SHA1 Message Date
sam 0c1479a3ee Upgrade to doxus 0.7.2
Build Mumble Web 2 / linux_build (push) Failing after 58s
Build Mumble Web 2 / windows_build (push) Has been cancelled
2025-12-04 22:22:35 -07:00
49 changed files with 1572 additions and 6585 deletions
-1
View File
@@ -1 +0,0 @@
target
@@ -1,27 +0,0 @@
name: Build android container
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *"
jobs:
android-release-builder-container-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: git.ohea.xyz
username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Build Android builder image
shell: bash
run: |
docker pull "$(grep -m1 '^FROM' ./docker/android-release-builder.Dockerfile | awk '{print $2}')"
docker build -t git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest -f ./docker/android-release-builder.Dockerfile .
docker push git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest
+1 -65
View File
@@ -18,7 +18,7 @@ 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
@@ -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:
@@ -124,26 +83,3 @@ jobs:
name: mumble-web2-gui-windows
path: gui/dist
retention-days: 5
android_build:
runs-on: ubuntu-latest
container:
image: git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: Swatinem/rust-cache@v2
- name: Build dioxus project (x86_64-linux-android)
run: dx build --platform android --target x86_64-linux-android --release -p mumble-web2-gui
- name: Build dioxus project (aarch64-linux-android)
run: dx build --platform android --target aarch64-linux-android --release -p mumble-web2-gui
- name: Upload mumble-web2-gui Android Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-android
path: target/dx/mumble-web2-gui/release/android/app/app/build/outputs/apk/debug/app-debug.apk
retention-days: 5
@@ -22,6 +22,6 @@ jobs:
- name: Build Windows image
shell: bash
run: |
docker pull "$(grep -m1 '^FROM' ./docker/windows-release-builder.Dockerfile | awk '{print $2}')"
docker pull "$(grep -m1 '^FROM' Dockerfile | awk '{print $2}')"
docker build -t git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest -f ./docker/windows-release-builder.Dockerfile .
docker push git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest
-4
View File
@@ -1,4 +0,0 @@
{
"rust-analyzer.cargo.features": ["desktop","web"],
"rust-analyzer.cargo.noDefaultFeatures": false
}
Generated
+814 -4331
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -2,12 +2,12 @@
## Running Desktop
1. `cargo install dioxus-cli --version 0.7.1`
1. `cargo install dioxus-cli --version 0.7.2`
2. `dx run -p mumble-web2-gui --platform desktop --release`
## Running Web (development)
1. `cargo install dioxus-cli --version 0.7.1`
1. `cargo install dioxus-cli --version 0.7.2`
3. `dx serve -p mumble-web2-gui --platform web`
2. `cd docker && docker compose up`
4. connect to `https://localhost:64444`
@@ -15,7 +15,7 @@
## Running Web (with `proxy` only)
1. `cargo install dioxus-cli --version 0.7.1`
1. `cargo install dioxus-cli --version 0.7.2`
2. `dx build -p mumble-web2-gui --platform web --release`
3. `cp config.toml.example config.toml`
4. `cargo run -p mumble-web2-proxy` in the background
+2 -1
View File
@@ -1,8 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProxyOverrides {
pub struct ClientConfig {
pub proxy_url: Option<String>,
pub status_url: Option<String>,
pub cert_hash: Option<Vec<u8>>,
pub any_server: bool,
}
+1 -1
View File
@@ -1,4 +1,4 @@
proxy_url = "https://127.0.0.1:4433/proxy"
public_url = "https://127.0.0.1:4433"
https_listen_address = "127.0.0.1:4433"
http_listen_address = "127.0.0.1:8080"
mumble_server_url = "[SERVER_URL_HERE]"
+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
}
-43
View File
@@ -1,43 +0,0 @@
FROM rust:trixie
ARG ANDROID_CLI_TOOLS_VERSION=13114758
# Install android rust toolchains
RUN rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
# Install debian dependencies
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
nodejs \
ca-certificates \
curl \
unzip \
default-jdk
# Install android commandline tools (required to install the sdk)
RUN cd /tmp && \
curl -o commandlinetools-linux.zip "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CLI_TOOLS_VERSION}_latest.zip" && \
unzip commandlinetools-linux.zip && \
mkdir -p /opt/android-tools/cmdline-tools && \
cp -r cmdline-tools /opt/android-tools/cmdline-tools/latest
# Install required android tools
RUN yes | /opt/android-tools/cmdline-tools/latest/bin/sdkmanager --install "platform-tools" "platforms;android-36.1" "build-tools;36.1.0" "ndk;29.0.14206865" "cmake;3.31.6"
# 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
# Install dioxus-cli
RUN cargo binstall dioxus-cli@0.7.3
# Install bindgen-cli
RUN cargo binstall bindgen-cli
# Set required env vars
ENV ANDROID_HOME="/opt/android-tools/"
ENV NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865"
ENV PATH="$PATH:$ANDROID_HOME/platform-tools"
ENV PATH="$PATH:/opt/android-tools/cmake/3.31.6/bin/"
ENV LLVM_CONFIG_PATH="/opt/android-tools/ndk/29.0.14206865/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-config"
-33
View File
@@ -1,33 +0,0 @@
FROM archlinux:latest
# Base system + toolchain deps
RUN pacman -Sy --noconfirm archlinux-keyring && \
pacman-key --init && \
pacman-key --populate archlinux && \
pacman -Syu --noconfirm && \
pacman -S --noconfirm --needed \
base-devel git sudo xdotool
# Create non-root build user for AUR
RUN useradd -m -G wheel -s /bin/bash builder && \
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
USER builder
WORKDIR /home/builder
# Install yay from AUR
RUN git clone https://aur.archlinux.org/yay.git /home/builder/yay && \
cd /home/builder/yay && \
makepkg -si --noconfirm
# Use yay to install claude-code (or claude-code-stable)
RUN yay -S --noconfirm claude-code
# Optional: switch back to root for cleanup
USER root
RUN rm -rf /home/builder/yay && \
pacman -Scc --noconfirm
# Default working user/environment
USER builder
WORKDIR /home/builder
+4 -4
View File
@@ -5,7 +5,7 @@ services:
- "64444:64444/tcp"
- "64444:64444/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:z
- ./Caddyfile:/etc/caddy/Caddyfile
#- caddy_data:/data
#- caddy_config:/config
depends_on:
@@ -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: >
@@ -35,8 +35,8 @@ services:
image: rust:latest
working_dir: /app
volumes:
- ..:/app:z
- ./proxy-config.toml:/app/config.toml:z
- ..:/app
- ./proxy-config.toml:/app/config.toml
ports:
- "4433:4433/tcp"
- "4433:4433/udp"
+1
View File
@@ -1,3 +1,4 @@
public_url = "https://localhost:64444"
proxy_url = "https://127.0.0.1:4433/proxy"
https_listen_address = "127.0.0.1:4433"
http_listen_address = "127.0.0.1:4400"
+3 -4
View File
@@ -44,12 +44,11 @@ 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
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
iex (iwr "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1").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
RUN cargo binstall dioxus-cli --version 0.7.2
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
+11 -31
View File
@@ -6,7 +6,7 @@ edition = "2021"
[dependencies]
# Web Dependencies
# ================
dioxus-web = { version = "0.7.1", optional = true }
dioxus-web = { version = "0.7.2", optional = true }
wasm-bindgen = { version = "^0.2.92", optional = true }
wasm-bindgen-futures = { version = "^0.4.42", optional = true }
wasm-streams = { version = "^0.4.0", optional = true }
@@ -30,6 +30,8 @@ web-sys = { version = "^0.3.72", features = [
"EncodedAudioChunkInit",
"EncodedAudioChunkType",
"CodecState",
"MediaStreamTrackGenerator",
"MediaStreamTrackGeneratorInit",
"AudioContext",
"AudioContextOptions",
"MediaStream",
@@ -40,7 +42,6 @@ web-sys = { version = "^0.3.72", features = [
"AudioWorkletNode",
"AudioWorklet",
"AudioWorkletProcessor",
"MessagePort",
"MediaStreamConstraints",
"WorkletOptions",
"AudioEncoder",
@@ -63,12 +64,10 @@ tokio-rustls = { version = "^0.26.0", optional = true }
opus = { version = "0.3.0", optional = true }
cpal = { version = "0.15.3", optional = true }
dasp_ring_buffer = { version = "0.11.0", optional = true }
etcetera = { version = "0.10.0", optional = true }
# Base Dependencies
# ================
dioxus = { version = "0.7.2" }
dioxus-native = { git = "https://github.com/DioxusLabs/blitz", rev = "e64a3d8", features = ["prelude"], optional = true }
once_cell = "1.19.0"
asynchronous-codec = { workspace = true }
futures = "^0.3.30"
@@ -89,31 +88,23 @@ tracing = "^0.1.40"
color-eyre = "^0.6.3"
crossbeam-queue = "^0.3.11"
lol_html = "^2.2.0"
rfd = { git = "https://github.com/samsartor/rfd.git", version = "^0.16.0", default-features = false }
base64 = "^0.22"
mime_guess = "^2.0.5"
async_cell = "^0.2.3"
reqwest = { version = "^0.12.22", features = ["json"] }
dioxus-asset-resolver = "0.7.2"
# Denoising
# =========
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
"tract",
] }
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = ["tract"] }
crossbeam = "0.8.4"
# Platform Integration
# ====================
# rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.17.0", default-features = false, optional = true }
# Android dependencies for requesting permissions
[target.'cfg(target_os = "android")'.dependencies]
android-permissions = "0.1.2"
jni = "0.21.1"
ndk-context = "0.1.1"
[patch.crates-io]
tract-hir = "=0.12.4"
tract-core = "=0.12.4"
tract-onnx = "=0.12.4"
tract-pulse = "=0.12.4"
[features]
web = [
@@ -128,7 +119,6 @@ web = [
"gloo-timers",
"tracing-web",
"deep_filter/wasm",
"rfd",
]
desktop = [
"dioxus/desktop",
@@ -139,15 +129,5 @@ desktop = [
"cpal",
"dasp_ring_buffer",
"rfd/xdg-portal",
"etcetera",
"rfd/tokio",
]
mobile = [
"dioxus/mobile",
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
]
blitz = ["dioxus-native"]
+2 -4
View File
@@ -8,8 +8,6 @@ out_dir = "dist"
# resource (public) file folder
asset_dir = "public"
android_manifest = "build/AndroidManifest.xml"
[web.app]
# HTML title tag content
title = "Mumble Web 2"
@@ -25,7 +23,7 @@ watch_path = ["src", "assets"]
# CSS style file
style = []
# Javascript code file
script = ["loader.js"]
script = []
[web.resource.dev]
# serve: [dev-server] only
@@ -35,7 +33,7 @@ style = []
script = []
[bundle]
identifier = "xyz.ohea.mumble_web_2"
identifier = "xyz.ohea.mumble-web-2"
publisher = "OheaCorp"
icon = [
"icons/32x32.png",
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M18 15.75q0 2.6-1.825 4.425T11.75 22t-4.425-1.825T5.5 15.75V6.5q0-1.875 1.313-3.187T10 2t3.188 1.313T14.5 6.5v8.75q0 1.15-.8 1.95t-1.95.8t-1.95-.8t-.8-1.95V6h2v9.25q0 .325.213.538t.537.212t.538-.213t.212-.537V6.5q-.025-1.05-.737-1.775T10 4t-1.775.725T7.5 6.5v9.25q-.025 1.775 1.225 3.013T11.75 20q1.75 0 2.975-1.237T16 15.75V6h2z"/></svg>

Before

Width:  |  Height:  |  Size: 453 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M11 21V7h2v14zm-4-3v-8h2v8zm8 0v-8h2v8zM3 15v-2h2v2zm16 0v-2h2v2zM2 10V8h1.175q1.05 0 1.963-.525T6.6 6.05q.85-1.425 2.288-2.238T12 3t3.113.813T17.4 6.05q.55.9 1.463 1.425T20.825 8H22v2h-1.15q-1.575 0-2.963-.775T15.7 7.1q-.575-.975-1.562-1.537T12 5q-1.125 0-2.113.563T8.326 7.1q-.8 1.35-2.187 2.125T3.175 10z"/></svg>

Before

Width:  |  Height:  |  Size: 431 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12.713 16.713Q13 16.425 13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17t.713-.288M11 13h2V7h-2zm1 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>

Before

Width:  |  Height:  |  Size: 392 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M7 18V6h2v12zm4 4V2h2v20zm-8-8v-4h2v4zm12 4V6h2v12zm4-4v-4h2v4z"/></svg>

Before

Width:  |  Height:  |  Size: 187 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M9.875 13.125Q9 12.25 9 11V5q0-1.25.875-2.125T12 2t2.125.875T15 5v6q0 1.25-.875 2.125T12 14t-2.125-.875M11 21v-3.075q-2.6-.35-4.3-2.325T5 11h2q0 2.075 1.463 3.538T12 16t3.538-1.463T17 11h2q0 2.625-1.7 4.6T13 17.925V21z"/></svg>

Before

Width:  |  Height:  |  Size: 342 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M17.75 14.95L16.3 13.5q.35-.575.525-1.2T17 11h2q0 1.1-.325 2.088t-.925 1.862m-2.95-3L9 6.15V5q0-1.25.875-2.125T12 2t2.125.875T15 5v6q0 .275-.062.5t-.138.45M11 21v-3.1q-2.6-.35-4.3-2.312T5 11h2q0 2.075 1.463 3.538T12 16q.85 0 1.613-.262T15 15l1.425 1.425q-.725.575-1.588.963T13 17.9V21zm8.8 1.6L1.4 4.2l1.4-1.4l18.4 18.4z"/></svg>

Before

Width:  |  Height:  |  Size: 444 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M14 21v-3.075l5.525-5.5q.225-.225.5-.325t.55-.1q.3 0 .575.113t.5.337l.925.925q.2.225.313.5t.112.55t-.1.563t-.325.512l-5.5 5.5zM4 20v-2.8q0-.85.438-1.562T5.6 14.55q1.55-.775 3.15-1.162T12 13q.925 0 1.825.113t1.8.362L12 17.1V20zm16.575-4.6l.925-.975l-.925-.925l-.95.95zm-11.4-4.575Q8 9.65 8 8t1.175-2.825T12 4t2.825 1.175T16 8t-1.175 2.825T12 12t-2.825-1.175"/></svg>

Before

Width:  |  Height:  |  Size: 480 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M3 20v-6l8-2l-8-2V4l19 8z"/></svg>

Before

Width:  |  Height:  |  Size: 149 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M5 20v-6h3v6zm6 0V9h3v11zm6 0V4h3v16z"/></svg>

Before

Width:  |  Height:  |  Size: 161 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M5 20v-6h3v6zm6 0V9h3v11z"/></svg>

Before

Width:  |  Height:  |  Size: 149 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="m17.1 14.275l-1.225-1.225q.55-.65.838-1.425T17 10q0-1-.4-1.9t-1.1-1.6l1.2-1.2q.95.95 1.475 2.15T18.7 10q0 1.2-.425 2.288T17.1 14.275M14.125 11.3L10.7 7.875q.3-.175.625-.275T12 7.5q1.05 0 1.775.725T14.5 10q0 .35-.1.675t-.275.625m5.375 5.35l-1.2-1.2q1-1.125 1.5-2.537T20.3 10q0-1.65-.612-3.187T17.9 4.1l1.2-1.2q1.375 1.45 2.138 3.275T22 10q0 1.85-.638 3.563T19.5 16.65m.275 5.95L13 15.825V21h-2v-7.175L7 9.85V10q0 1 .4 1.9t1.1 1.6l-1.2 1.2q-.95-.95-1.475-2.15T5.3 10q0-.425.05-.825t.175-.825L4.25 7.075q-.275.725-.413 1.45T3.7 10q0 1.65.612 3.188T6.1 15.9l-1.2 1.2q-1.375-1.45-2.137-3.275T2 10q0-1.1.238-2.162t.712-2.063L1.4 4.225L2.8 2.8l18.4 18.4z"/></svg>

Before

Width:  |  Height:  |  Size: 771 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="m19.8 22.6l-3.025-3.025q-.625.4-1.325.688t-1.45.462v-2.05q.35-.125.688-.25t.637-.3L12 14.8V20l-5-5H3V9h3.2L1.4 4.2l1.4-1.4l18.4 18.4zm-.2-5.8l-1.45-1.45q.425-.775.638-1.625t.212-1.75q0-2.35-1.375-4.2T14 5.275v-2.05q3.1.7 5.05 3.138T21 11.975q0 1.325-.363 2.55T19.6 16.8m-3.35-3.35L14 11.2V7.95q1.175.55 1.838 1.65T16.5 12q0 .375-.062.738t-.188.712M12 9.2L9.4 6.6L12 4z"/></svg>

Before

Width:  |  Height:  |  Size: 492 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M14 20.725v-2.05q2.25-.65 3.625-2.5t1.375-4.2t-1.375-4.2T14 5.275v-2.05q3.1.7 5.05 3.138T21 11.975t-1.95 5.613T14 20.725M3 15V9h4l5-5v16l-5-5zm11 1V7.95q1.175.55 1.838 1.65T16.5 12q0 1.275-.663 2.363T14 16"/></svg>

Before

Width:  |  Height:  |  Size: 329 B

+17 -158
View File
@@ -83,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%;
@@ -173,7 +135,6 @@ a:visited {
&_box {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
background-color: var(--light-bg-color);
@@ -186,12 +147,6 @@ a:visited {
border-radius: 8px;
.material-symbols-outlined {
width: 35px;
height: 35px;
cursor: pointer;
}
input {
color: white;
background-color: var(--light-bg-color);
@@ -213,78 +168,40 @@ a:visited {
background-color: oklch(0.53 0.1431 264.18);
border-radius: 50%;
aspect-ratio: 1 / 1;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(4px, 0.5vw, 8px);
}
.button_row {
display: flex;
gap: clamp(4px, 1vw, 10px);
align-items: center;
flex-wrap: nowrap;
min-height: 0;
gap: 10px;
.spacer {
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
}
.connection_status {
display: flex;
flex-direction: column;
min-width: 0;
flex-shrink: 1;
> div {
display: flex;
align-items: center;
}
}
.user_info {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
flex-shrink: 1;
.user_name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user_data {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.toggle_button {
padding: clamp(4px, 0.5vw, 8px);
padding: 8px;
height: 100%;
aspect-ratio: 1 / 1;
flex-shrink: 0;
background-color: unset;
border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px);
border-radius: clamp(4px, 0.8vw, 10px);
border: solid rgb(255 255 255 / 0.1) 3px;
border-radius: 10px;
color: rgb(255 255 255 / 50%);
transition: all 0.5s ease-in-out;
&.is_on {
background-color: oklch(0.5 0.1381 21.71 / 20.12%);
color: oklch(0.53 0.1505 21.71 / 89.38%);
}
display: flex;
align-items: center;
justify-content: center;
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
vertical-align: middle;
font-size: 35px;
}
}
.server {
@@ -328,69 +245,16 @@ a:visited {
}
&_control_box {
padding: clamp(6px, 0.8vw, 12px);
margin: clamp(6px, 0.8vw, 12px);
padding: 16px;
margin: 16px;
background-color: var(--light-bg-color);
border-radius: clamp(6px, 1vw, 10px);
border-radius: 10px;
overflow: hidden;
grid-area: control;
display: flex;
gap: clamp(4px, 0.8vw, 8px);
gap: 10px;
flex-direction: column;
// Dynamic font sizing for control elements
--control-icon-size: clamp(16px, 2.5vw, 30px);
--control-text-size: clamp(12px, 2vw, 25px);
--control-small-text-size: clamp(10px, 1.5vw, 20px);
--user-icon-size: clamp(24px, 4vw, 45px);
--toggle-icon-size: clamp(18px, 3vw, 35px);
.connection_status {
.material-symbols-outlined {
width: 24px;
width: var(--control-icon-size);
height: 24px;
height: var(--control-icon-size);
}
.status_text {
font-size: var(--control-text-size);
}
.channel_text {
font-size: var(--control-small-text-size);
}
}
.user_edit_button {
.material-symbols-outlined {
width: 36px;
width: var(--user-icon-size);
height: 36px;
height: var(--user-icon-size);
}
}
.user_info {
.user_name {
font-size: var(--control-text-size);
}
.user_data {
font-size: var(--control-small-text-size);
}
}
.toggle_button {
.material-symbols-outlined {
width: 28px;
width: var(--toggle-icon-size);
height: 28px;
height: var(--toggle-icon-size);
}
}
hr {
margin: 0;
}
}
}
@@ -415,11 +279,6 @@ a:visited {
color: #b3c6b4;
}
&_version {
color: var(--txt-color);
font-weight: normal;
}
&_bttn {
font-weight: bold;
font-size: large;
@@ -443,4 +302,4 @@ a:visited {
color: red;
}
}
}
}
@@ -1,7 +1,7 @@
const SAMPLE_RATE = 48000;
const PACKET_SAMPLES = 960;
class RustMicWorklet extends AudioWorkletProcessor {
class RustWorklet extends AudioWorkletProcessor {
constructor(options) {
super();
this.module = options.processorOptions;
@@ -31,7 +31,7 @@ class RustMicWorklet extends AudioWorkletProcessor {
}
this.buffer_offset -= PACKET_SAMPLES;
this.timestamp = null;
}
}
process(inputs) {
//console.log(inputs);
@@ -60,44 +60,4 @@ class RustMicWorklet extends AudioWorkletProcessor {
}
};
class RustSpeakerWorklet extends AudioWorkletProcessor {
constructor() {
super();
this.queue = [];
this.readIndex = 0;
this.port.onmessage = (event) => {
this.queue.push(event.data)
};
}
process(inputs, outputs) {
if (this.queue.length) {
console.log(this.queue[0].samples.length, outputs[0][0].length);
}
const output = outputs[0];
for (let i = 0; i < output[0].length; i++) {
if (!this.queue.length) {
return true;
}
const current = this.queue[0];
for (let ch = 0; ch < output.length; ch++) {
output[ch][i] = current.samples[this.readIndex];
}
this.readIndex++;
if (this.readIndex >= current.samples.length) {
this.queue.shift();
this.readIndex = 0;
}
}
return true;
}
};
registerProcessor("rust_mic_worklet", RustMicWorklet);
registerProcessor("rust_speaker_worklet", RustSpeakerWorklet);
registerProcessor("rust_mic_worklet", RustWorklet);
+8 -56
View File
@@ -1,39 +1,7 @@
use std::env;
use std::path::Path;
use std::process::Command;
fn version_env() -> Option<()> {
if env::var("MUMBLE_WEB2_VERSION").is_ok() {
return Some(());
}
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
let git_hash = String::from_utf8(output.stdout).ok()?;
let git_hash = git_hash.trim(); // drop trailing newline
let status = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()?;
let dirty = match status.stdout.is_empty() {
true => "",
false => "-dirty",
};
// Expose it as a compile-time env var
println!("cargo::rustc-env=MUMBLE_WEB2_VERSION=git-{git_hash}{dirty}");
// Optional: rebuild when HEAD changes
println!("cargo::rerun-if-changed=.git/HEAD");
Some(())
}
fn download_deepfilternet() {
fn main() {
// Define the target directory and file
let assets_dir = "assets";
let target_file = format!("{}/DeepFilterNet3_ll_onnx.tar.gz", assets_dir);
@@ -41,46 +9,30 @@ fn download_deepfilternet() {
// Check if the file already exists
if target_path.exists() {
println!(
"cargo::warning=DeepFilterNet model already exists at {}",
target_file
);
println!("cargo:warning=DeepFilterNet model already exists at {}", target_file);
return;
}
println!(
"cargo::warning=Downloading DeepFilterNet model to {}...",
target_file
);
println!("cargo:warning=Downloading DeepFilterNet model to {}...", target_file);
// Download the file using curl
let url = "https://github.com/Rikorose/DeepFilterNet/raw/refs/heads/main/models/DeepFilterNet3_ll_onnx.tar.gz";
let status = Command::new("curl")
.args([
"-L", // Follow redirects
"-o",
&target_file, // Output file
"-L", // Follow redirects
"-o", &target_file, // Output file
url,
])
.status()
.expect("Failed to execute curl command. Make sure curl is installed.");
if !status.success() {
println!("cargo::error=Failed to download DeepFilterNet model from {url}");
return;
panic!("Failed to download DeepFilterNet model from {}", url);
}
println!(
"cargo::warning=Successfully downloaded DeepFilterNet model to {}",
target_file
);
println!("cargo:warning=Successfully downloaded DeepFilterNet model to {}", target_file);
// Rerun this build script if the target file is deleted
println!("cargo::rerun-if-changed={}", target_file);
}
fn main() {
version_env();
download_deepfilternet();
println!("cargo:rerun-if-changed={}", target_file);
}
-32
View File
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<!--
Borrowed from https://github.com/irh/audio-app/blob/main/apps/dioxus/AndroidManifest.xml
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
<uses-feature android:name="android.hardware.audio.output" android:required="false" />
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application android:hasCode="true" android:supportsRtl="true" android:icon="@mipmap/ic_launcher"
android:extractNativeLibs="true"
android:allowNativeHeapPointerTagging="false"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_security_config">
<activity android:configChanges="orientation|screenLayout|screenSize|keyboardHidden" android:exported="true"
android:label="@string/app_name" android:name="dev.dioxus.main.MainActivity">
<meta-data android:name="android.app.lib_name" android:value="dioxusmain" />
<meta-data android:name="android.app.func_name" android:value="ANativeActivity_onCreate" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
-68
View File
@@ -1,68 +0,0 @@
// Loading screen that displays while WASM loads
(function() {
// Create and inject loader styles immediately (head exists)
var style = document.createElement('style');
style.textContent =
'.wasm-loader {' +
'position: fixed;' +
'top: 0;' +
'left: 0;' +
'width: 100%;' +
'height: 100%;' +
'background-color: oklch(0.15 0.01 338.64);' +
'display: flex;' +
'align-items: center;' +
'justify-content: center;' +
'z-index: 9999;' +
'transition: opacity 0.3s ease-out;' +
'}' +
'.wasm-loader.hidden {' +
'opacity: 0;' +
'pointer-events: none;' +
'}' +
'.wasm-spinner {' +
'width: 48px;' +
'height: 48px;' +
'border: 4px solid rgba(123, 173, 159, 0.2);' +
'border-top-color: #7bad9f;' +
'border-radius: 50%;' +
'animation: wasm-spin 1s linear infinite;' +
'}' +
'@keyframes wasm-spin {' +
'to { transform: rotate(360deg); }' +
'}' +
'#main {' +
'background-color: oklch(0.15 0.01 338.64);' +
'}';
document.head.appendChild(style);
function init() {
// Create loader element
var loader = document.createElement('div');
loader.className = 'wasm-loader';
loader.innerHTML = '<div class="wasm-spinner"></div>';
document.body.appendChild(loader);
// Watch for Dioxus to mount content in #main
var observer = new MutationObserver(function(mutations, obs) {
var main = document.getElementById('main');
if (main && main.children.length > 0) {
loader.classList.add('hidden');
setTimeout(function() { loader.remove(); }, 300);
obs.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Wait for body to exist
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
})();
+149 -315
View File
@@ -1,72 +1,12 @@
#![allow(non_snake_case)]
#[cfg(feature = "blitz")]
use dioxus_native::prelude::*;
#[cfg(not(feature = "blitz"))]
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::collections::HashMap;
use crate::imp::{ConfigSystem, ConfigSystemInterface as _, Platform, PlatformInterface as _};
// Material Symbols icon component.
// On blitz builds, renders <img> with a data URI SVG containing the explicit fill color,
// since web fonts aren't available and <img> can't inherit CSS color.
// On non-blitz builds, renders the icon font span as usual.
fn icon_svg_path(name: &str) -> &'static str {
// Paths from Google Material Symbols Outlined, weight 700, FILL 1, 24px.
// Coordinate space: viewBox="0 -960 960 960"
match name {
"attach_file" => "M772-320q0 117-87 195.5T479-46q-119 0-205-78.5T188-320v-392q0-86 63.5-144T403-914q88 0 150.5 58T616-712v371q0 55-40.5 92.5T479-211q-56 0-95.5-37.5T344-341v-370h116v370q0 7 5.5 11t13.5 4q8 0 14.5-3.5T500-341v-370q0-38-29-63t-68-25q-39 0-69 24.5T304-712v392q0 69 52.5 114T480-161q71 0 123.5-45T656-320v-429h116v429Z",
"cadence" => "M417-86v-555h125v555H417ZM252-210v-308h125v308H252Zm330 0v-308h125v308H582ZM86-301v-126h126v126H86Zm661 0v-126h126v126H747ZM46-542v-125h69q37.98 0 70.99-18.5T239-737q37.96-64.01 102.17-100.5 64.2-36.5 139.02-36.5 74.81 0 138.87 36.5Q683.12-801.01 721-737q19.75 32.31 52.52 51.15Q806.29-667 844-667h70v125h-69q-72 0-133-34.5T614-672q-20.82-35.75-56.59-56.38Q521.65-749 480-749q-42 0-77.63 20.62Q366.75-707.75 346-672q-37 61-98 95.5T115-542H46Z",
"error" => "M479.77-246Q509-246 529-265.77q20-19.77 20-49t-19.77-49.73q-19.77-20.5-49-20.5T431-364.5q-20 20.5-20 49.73 0 29.23 19.77 49t49 19.77ZM417-438h126v-263H417v263Zm63 392q-91 0-169.99-34.08-78.98-34.09-137.41-92.52-58.43-58.43-92.52-137.41Q46-389 46-480q0-91 34.08-169.99 34.09-78.98 92.52-137.41 58.43-58.43 137.41-92.52Q389-914 480-914q91 0 169.99 34.08 78.98 34.09 137.41 92.52 58.43 58.43 92.52 137.41Q914-571 914-480q0 91-34.08 169.99-34.09 78.98-92.52 137.41-58.43 58.43-137.41 92.52Q571-46 480-46Z",
"graphic_eq" => "M255-215v-530h111v530H255ZM425-46v-868h110v868H425ZM86-385v-190h111v190H86Zm507 170v-530h111v530H593Zm170-170v-190h111v190H763Z",
"mic" => "M479.88-354Q414-354 368-400.08 322-446.17 322-512v-233q0-65.83 46.12-111.92 46.12-46.08 112-46.08T592-856.92q46 46.09 46 111.92v233q0 65.83-46.12 111.92-46.12 46.08-112 46.08ZM425-59v-127q-121-16-199-109.12T148-512h111q0 92 64.7 156.5T480.2-291q91.8 0 156.3-64.64Q701-420.29 701-512h111q0 124-78 217T535-186v127H425Z",
"mic_off" => "m772-347-82-82q9-18 13-37.5t4-45.5h110q0 44-11 87t-34 78ZM639-480 336-783v-11q13-44 54-76.5t95-32.5q66 0 112.5 46T644-745v233q0 9-1.5 18t-3.5 14ZM430-59v-127q-121-16-199-109t-78-217h111q0 92 64.5 156.5T485-291q43 0 81.5-15.5T634-349l80 80q-35 33-79 54.5T540-186v127H430Zm357-5L51-800l66-66 736 736-66 66Z",
"person_edit" => "M554-86v-151l227-226q12-12.18 26.67-17.59Q822.33-486 837-486q16 0 30.55 6T894-462l37 37q10.82 12 16.91 26.67Q954-383.67 954-369q0 16-5.5 30.5T931-312L705-86H554Zm-428-23v-148q0-43.3 22.7-79.6 22.69-36.3 60.3-55.4 65-32 132.96-48.5Q409.92-457 480-457q42 0 81.33 4.97Q600.67-447.05 640-436L474-270v161H126Zm721-231 27-29-37-37-28 28 38 38ZM480-497q-81 0-137.5-56.5T286-691q0-81 56.5-137T480-884q81 0 137.5 56T674-691q0 81-56.5 137.5T480-497Z",
"send" => "M89-128v-244l366-108L89-588v-244l831 352L89-128Z",
"signal_cellular_alt" => "M176-126v-208h166v208H176Zm246 0v-408h166v408H422Zm246 0v-708h166v708H668Z",
"signal_cellular_alt_2_bar" => "M176-126v-208h166v208H176Zm246 0v-408h166v408H422Z",
"signal_disconnected" => "m703-385-66-66q17-24 26-52t9-57q0-38-15-73t-42-62l65-65q40 40 62.5 91.5T765-560q0 48-16.5 92.5T703-385ZM588-500 420-668q14-8 29-11.5t31-3.5q51 0 87 36t36 87q0 16-3.5 31T588-500Zm225 224-66-66q38-46 58.5-102T826-560q0-69-26.5-132.5T724-804l65-66q62 62 95.5 142T918-560q0 78-27 151t-78 133Zm16 263L543-299v202H417v-328L288-554v2q2 36 16.5 68.5T345-425l-65 65q-41-40-63-91.5T195-560q0-20 2.5-38.5T206-636l-48-48q-11 30-17.5 61t-6.5 63q0 69 26.5 132T236-316l-66 66q-60-63-94-142.5T42-560q0-51 11-100t34-94l-74-75 67-67L896-80l-67 67Z",
"volume_off" => "M802-24 L679-149q-21 12-44.5 21T585-114v-99l12-4q6-2 12-5L505-328v249L258-326H78v-308h130L22-826l66-66L869-91l-67 67Zm18-253-69-71q17-30 25.5-63.5T785-481q0-93-56-166.5T585-749v-99q130 29 213 131.5T881-481q0 57-16 108t-45 96ZM687-413 585-517v-137q51 24 83.5 70.5T701-480q0 18-3.5 35T687-413ZM505-600 367-743l138-138v281Z",
"volume_up" => "M586-114v-99q89-28 144.5-101.5T786-481q0-93-55.5-166.5T586-749v-99q130 29 212.5 131.5T881-481q0 133-82.5 235.5T586-114ZM79-326v-308h180l247-247v802L259-326H79Zm507 18v-346q51 25 83 71t32 103q0 56-32 102t-83 70Z",
_ => panic!("unknown icon: {name}"),
}
}
fn icon_data_uri(name: &str, color: &str, opacity: f64) -> String {
use base64::Engine;
let path_d = icon_svg_path(name);
let opacity_attr = if opacity < 1.0 {
format!(r#" fill-opacity="{opacity}""#)
} else {
String::new()
};
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="{color}"{opacity_attr} d="{path_d}"/></svg>"#
);
let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
format!("data:image/svg+xml;base64,{b64}")
}
#[component]
fn Icon(
name: String,
#[props(default)] style: String,
#[props(default)] color: String,
#[props(default = 1.0)] opacity: f64,
) -> Element {
let fill = if color.is_empty() { "white" } else { &color };
let src = icon_data_uri(&name, fill, opacity);
rsx!(img {
class: "material-symbols-outlined",
style: "{style}",
src: "{src}",
})
}
use crate::imp;
pub type ChannelId = u32;
pub type UserId = u32;
@@ -83,7 +23,7 @@ pub enum Command {
Connect {
address: String,
username: String,
config: ProxyOverrides,
config: ClientConfig,
},
SendChat {
markdown: String,
@@ -114,27 +54,30 @@ pub enum Command {
use Command::*;
use ConnectionState::*;
#[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,
pub deaf: bool,
pub mute: bool,
pub suppress: bool,
pub self_deaf: bool,
pub self_mute: bool,
}
impl UserState {
pub fn icon(&self) -> UserIcon {
if self.deaf || self.self_deaf {
UserIcon::Deafened
} else if self.mute || self.self_mute {
UserIcon::Muted
} else if self.suppress {
UserIcon::Suppressed
} else {
UserIcon::Normal
match (self.mute || self.self_mute, self.deaf || self.self_deaf) {
(false, false) => UserIcon::Normal,
(true, false) => UserIcon::Muted,
(_, true) => UserIcon::Deafened,
}
}
}
@@ -145,122 +88,9 @@ pub struct Chat {
pub sender: Option<UserId>,
}
#[derive(Default)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
pub position: i32,
}
impl ChannelState {
pub fn update_from_channel_state(
&mut self,
channel_state: &mumble_protocol::control::msgs::ChannelState,
) {
if channel_state.has_position() {
self.position = channel_state.get_position();
}
if channel_state.has_parent() {
self.parent = Some(channel_state.get_parent());
}
if channel_state.has_name() {
self.name = channel_state.get_name().to_string();
}
}
}
#[derive(Default)]
pub struct ChannelsState {
pub channels: HashMap<ChannelId, ChannelState>,
}
impl ChannelsState {
pub fn update_from_channel_state(
&mut self,
channel_state: &mumble_protocol::control::msgs::ChannelState,
) {
self.channels
.entry(channel_state.get_channel_id())
.or_default()
.update_from_channel_state(channel_state);
self.update_channel_parents();
}
pub fn update_from_channel_remove(
&mut self,
channel_remove: &mumble_protocol::control::msgs::ChannelRemove,
) {
self.channels.remove(&channel_remove.get_channel_id());
self.update_channel_parents();
}
pub fn update_channel_parents(&mut self) {
// Zero out existing children
for state in self.channels.values_mut() {
state.children.clear();
}
let mut to_sort: Vec<(ChannelId, Option<ChannelId>, i32, String)> = Vec::new();
for (id, state) in self.channels.iter() {
// Handle channels with no parent (the root channel)
let Some(parent_id) = state.parent else {
to_sort.push((*id, None, 0, state.name.clone()));
continue;
};
// If a channel has a parent that we haven't gotten a channel
// state packet for, ignore it
if !self.channels.contains_key(&parent_id) {
continue;
}
to_sort.push((*id, Some(parent_id), state.position, state.name.clone()));
}
let pos_name: HashMap<ChannelId, (i32, String)> = self
.channels
.iter()
.map(|(&id, state)| (id, (state.position, state.name.clone())))
.collect();
let mut updated: HashSet<ChannelId> = HashSet::new();
while updated.len() < to_sort.len() {
for &(id, ref parent_id, position, ref name) in &to_sort {
let Some(parent_id) = parent_id else {
updated.insert(id);
continue;
};
if updated.contains(&id) || !updated.contains(&parent_id) {
continue;
}
// Unwrap should never fail here since we pre filter
let parent = self.channels.get_mut(&parent_id).unwrap();
let mut insert_index = parent.children.len();
for (i, &child) in parent.children.iter().enumerate() {
let (p, ref n) = pos_name[&child];
if (position == p && name < n) || p > position {
insert_index = i;
break;
}
}
parent.children.insert_before(insert_index, id);
updated.insert(id);
}
}
}
}
#[derive(Default)]
pub struct ServerState {
pub channels_state: ChannelsState,
pub channels: HashMap<ChannelId, ChannelState>,
pub users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>,
pub session: Option<UserId>,
@@ -287,7 +117,6 @@ pub enum UserIcon {
Normal,
Muted,
Deafened,
Suppressed,
None,
}
@@ -299,7 +128,7 @@ impl UserIcon {
use UserIcon::*;
Some(match self {
Normal => asset!("assets/mic-svgrepo-com.svg"),
Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"),
Muted => asset!("assets/mic-off-svgrepo-com.svg"),
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
None => return Option::None,
})
@@ -311,7 +140,7 @@ pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
let color = match icon {
UserIcon::Normal => "var(--accent-normal)",
UserIcon::Muted => "var(--accent-muted)",
UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)",
UserIcon::Deafened => "var(--accent-deafened)",
UserIcon::None => "var(--accent-normal)",
};
@@ -347,57 +176,26 @@ pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let user = server.session.unwrap();
let Some(state) = server.channels_state.channels.get(&id) else {
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() {
@@ -412,7 +210,6 @@ 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() {
vec![user.channel]
@@ -434,8 +231,6 @@ pub fn pick_and_send_file(net: &Coroutine<Command>) {
});
});
}
#[cfg(not(any(feature = "desktop", feature = "web")))]
pub fn pick_and_send_file(net: &Coroutine<Command>) {}
#[component]
pub fn ChatView() -> Element {
@@ -490,13 +285,17 @@ pub fn ChatView() -> Element {
div {
span {
onclick: move |_| pick_and_send_file(&net),
Icon { name: "attach_file", color: "#ffffff", opacity: 0.5 }
class: "material-symbols-outlined",
style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
"attach_file",
}
}
div {
span {
onclick: move |_| do_send(),
Icon { name: "send", color: "#ffffff", opacity: 0.5 }
class: "material-symbols-outlined",
style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
"send",
}
}
}
@@ -510,7 +309,7 @@ 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 status = &STATE.status;
let server = STATE.server.read();
@@ -518,7 +317,6 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
deaf,
self_deaf,
mute,
suppress,
self_mute,
ref name,
channel,
@@ -528,12 +326,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)";
@@ -543,57 +341,79 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
let connection_status = match &*status.read() {
Connecting => rsx! {
div {
class: "connection_status",
style: "color: {connecting_color};",
div {
Icon { name: "signal_cellular_alt_2_bar", color: "yellow" }
span {
class: "status_text",
" Connecting"
}
style: "color: \"{connecting_color}\";",
span {
class: "material-symbols-outlined",
style: "vertical-align: middle; font-size: 30px;",
"signal_cellular_alt_2_bar"
}
span {
style: "width: 5px; display: inline-block;"
}
span {
style: "vertical-align: middle; font-size: 30px;",
"Connecting"
}
}
},
Connected => rsx! {
div {
class: "connection_status",
div {
style: "color: {connected_color};",
Icon { name: "signal_cellular_alt", color: "#46823e" }
style: "color: \"{connected_color}\";",
span {
class: "status_text",
" Connected"
class: "material-symbols-outlined",
style: "vertical-align: middle; font-size: 30px;",
"signal_cellular_alt"
}
span {
style: "width: 5px; display: inline-block;"
}
span {
style: "vertical-align: middle; font-size: 25px;",
"Connected"
}
}
div {
class: "channel_text",
span { style: "width: 3px; display: inline-block;"}
span { "{current_channel_name}" }
if let Some(proxy_url) = proxy_url {
span { "" }
span { "{proxy_url}" }
}
}
}
},
Disconnected => rsx! {
div {
class: "connection_status",
style: "color: {disconnected_color};",
div {
Icon { name: "signal_disconnected", color: "gray" }
span {
class: "status_text",
" Disconnected"
}
style: "color: \"{disconnected_color}\";",
span {
class: "material-symbols-outlined",
style: "vertical-align: middle;",
"signal_disconnected"
}
span {
style: "width: 5px; display: inline-block;"
}
span {
style: "vertical-align: middle;",
"Disconnected"
}
}
},
Failed(_) => rsx! {
div {
class: "connection_status",
style: "color: {failed_color};",
div {
Icon { name: "error", color: "red" }
span {
class: "status_text",
" Failed"
}
style: "color: \"{failed_color}\";",
span {
class: "material-symbols-outlined",
style: "vertical-align: middle;",
"error"
}
span {
style: "width: 5px; display: inline-block;"
}
span {
style: "vertical-align: middle;",
"Failed"
}
}
},
@@ -611,7 +431,10 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
button {
class: "toggle_button",
onclick: move |_| net.send(Disconnect),
Icon { name: "signal_disconnected", color: "#ffffff", opacity: 0.5 }
span {
class: "material-symbols-outlined",
"signal_disconnected"
}
}
}
hr { style: "width: 100%;" }
@@ -620,15 +443,18 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
class: "button_row",
button {
class: "user_edit_button",
Icon { name: "person_edit", color: "#fa3f36" }
span {
class: "material-symbols-outlined",
style: "color: oklch(0.65 0.2245 28.06); font-size: 45px; font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;",
"person_edit"
}
}
div {
class: "user_info",
div {
span { class: "user_name", "{name}" }
span { style: "font-size: 25px;", "{name}" }
}
div {
span { class: "user_data", "some data" }
span { style: "font-size: 20px; color: gray;", "some data" }
}
}
span { class: "spacer" }
@@ -645,22 +471,22 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
net.send(UpdateMicEffects { denoise: new_denoise })
},
match denoise() {
true => rsx!(Icon { name: "cadence", color: "#b23f43", opacity: 0.8938 }),
false => rsx!(Icon { name: "graphic_eq", color: "#ffffff", opacity: 0.5 }),
true => rsx!(span { class: "material-symbols-outlined", "cadence"}),
false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}),
}
}
button {
class: match mute || suppress || self_mute {
class: match mute || self_mute {
true => "toggle_button is_on",
false => "toggle_button",
},
role: "switch",
aria_checked: mute || suppress || self_mute,
disabled: mute || suppress,
aria_checked: mute || self_mute,
disabled: mute,
onclick: move |_| net.send(SetMute { mute: !self_mute }),
match mute || suppress || self_mute {
true => rsx!(Icon { name: "mic_off", color: "#b23f43", opacity: 0.8938 }),
false => rsx!(Icon { name: "mic", color: "#ffffff", opacity: 0.5 }),
match mute || self_mute {
true => rsx!(span { class: "material-symbols-outlined", "mic_off"}),
false => rsx!(span { class: "material-symbols-outlined", "mic"}),
}
}
button {
@@ -673,8 +499,8 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
disabled: deaf,
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
match deaf || self_deaf {
true => rsx!(Icon { name: "volume_off", color: "#b23f43", opacity: 0.8938 }),
false => rsx!(Icon { name: "volume_up", color: "#ffffff", opacity: 0.5 }),
true => rsx!(span { class: "material-symbols-outlined", "volume_off"}),
false => rsx!(span { class: "material-symbols-outlined", "volume_up"}),
}
}
}
@@ -682,7 +508,7 @@ pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
}
#[component]
pub fn ServerView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem) -> Element {
pub fn ServerView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let Some(&UserState {
@@ -701,7 +527,7 @@ pub fn ServerView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem
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 }
}
@@ -713,48 +539,64 @@ pub fn ServerView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem
}
div {
class: "server_control_box",
ControlView { overrides }
ControlView { config }
}
}
)
}
async fn get_status(
client: &reqwest::Client,
status_url: &str,
) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(status_url)
.send()
.await?
.json::<ServerStatus>()
.await?)
}
#[component]
pub fn LoginView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem) -> Element {
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 Some(config) = config.read().clone() else {
return;
};
let Some(status_url) = config.status_url else {
return;
};
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(get_status(&client, &status_url).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(|| None::<String>);
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 previous_username = user_config.config_get::<String>("username");
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());
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 status = &STATE.status;
@@ -788,18 +630,13 @@ pub fn LoginView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem)
),
Connected => unreachable!(),
};
let version = option_env!("MUMBLE_WEB2_VERSION");
rsx!(
div {
class: "login",
h1 {
"Mumble Web"
match version {
Some(v) => rsx!(" " span { class: "login_version", "({v})" }),
None => rsx!(),
}
}
if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) {
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
div {
label {
for: "address-entry",
@@ -895,24 +732,21 @@ pub fn app() -> Element {
static STYLE: Asset = asset!("/assets/main.scss");
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
let overrides = use_resource(|| async move {
match Platform::load_proxy_overrides().await {
Ok(overrides) => overrides,
Err(_) => ProxyOverrides::default(),
let config = use_resource(|| async move {
match imp::load_config().await {
Ok(config) => config,
Err(_) => ClientConfig::default(),
}
});
let user_config = ConfigSystem::new().unwrap();
Platform::request_permissions();
rsx!(
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" }
document::Link{ rel: "stylesheet", href: STYLE }
match *STATE.status.read() {
Connected => rsx!(ServerView { overrides, user_config }),
_ => rsx!(LoginView { overrides, user_config }),
Connected => rsx!(ServerView { config }),
_ => rsx!(LoginView { config }),
}
)
}
+11 -74
View File
@@ -1,33 +1,15 @@
use crossbeam::atomic::AtomicCell;
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
use df::tract::{DfParams, DfTract, RuntimeParams};
#[cfg(feature = "blitz")]
use dioxus_native::prelude::{asset, manganis, Asset};
#[cfg(not(feature = "blitz"))]
use dioxus::prelude::{asset, manganis, Asset};
use dioxus_asset_resolver::read_asset_bytes;
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.
static DEFAULT_NOISE_FLOOR: f32 = 0.001;
// 200ms hold at 48kHz sample rate
static HOLD_SAMPLES_MAX: usize = 48000 / 5; // 9600 samples = 200ms
/// Indicates the transmission state after processing audio.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransmitState {
/// Audio is above threshold, or below but within hold period - transmit normally
Transmitting,
/// Hold period expired - send this frame as terminator (end_bit = true)
Terminator,
/// Silent and not transmitting - don't send anything
Silent,
}
enum DenoisingModelState {
Nothing,
@@ -35,7 +17,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! {
@@ -89,52 +74,36 @@ 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
was_transmitting: bool,
/// Number of samples we've been below threshold (for hold period)
hold_samples: usize,
}
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,
hold_samples: 0,
}
}
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,
hold_samples: 0,
}
}
}
impl AudioProcessor {
pub fn process(
&mut self,
audio: &[f32],
channels: usize,
output: &mut Vec<f32>,
) -> TransmitState {
pub fn process(&mut self, audio: &[f32], output: &mut Vec<f32>) {
let mut include_raw = true;
if self.denoise {
with_denoising_model(&self.spawn, |df| {
include_raw = false;
self.buffer.extend(audio.iter().step_by(channels).copied());
self.buffer.extend_from_slice(audio);
output.reserve(audio.len());
let hop = df.hop_size;
@@ -161,40 +130,8 @@ impl AudioProcessor {
}
if include_raw {
output.extend(audio.iter().step_by(channels).copied());
output.extend_from_slice(audio);
}
// Calculate average amplitude for VAD
let avg: f32 = if output.is_empty() {
0.0
} else {
output.iter().map(|x| x.abs()).sum::<f32>() / output.len() as f32
};
let above_threshold = avg >= self.noise_floor;
let samples_in_frame = output.len();
let state = if above_threshold {
// Above threshold - reset hold counter and transmit
self.hold_samples = 0;
self.was_transmitting = true;
TransmitState::Transmitting
} else if self.was_transmitting && self.hold_samples < HOLD_SAMPLES_MAX {
// Below threshold but in hold period - keep transmitting
self.hold_samples += samples_in_frame;
TransmitState::Transmitting
} else if self.was_transmitting {
// Hold period expired - send terminator
self.was_transmitting = false;
self.hold_samples = 0;
TransmitState::Terminator
} else {
// Not transmitting and below threshold - stay silent
output.clear(); // Don't accumulate stale audio during silence
TransmitState::Silent
};
state
}
}
-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();
}
}
-115
View File
@@ -1,115 +0,0 @@
use crate::app::Command;
use color_eyre::eyre::{bail, Error};
use dioxus::hooks::UnboundedReceiver;
use mumble_protocol::control::ClientControlCodec;
use std::net::ToSocketAddrs;
use std::sync::Arc;
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::DigitallySignedStruct;
use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{info, instrument};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
#[derive(Debug)]
struct NoCertificateVerification;
impl ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}
#[instrument]
pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
) -> Result<(), Error> {
info!("connecting");
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let addr = format!("{}:{}", address, 64738)
.to_socket_addrs()?
.next()
.unwrap();
let server_tcp = TcpStream::connect(addr).await?;
let server_stream = connector
//.connect("127.0.0.1".try_into()?, server_tcp)
.connect(address.try_into()?, server_tcp)
.await?;
let (read_server, write_server) = tokio::io::split(server_stream);
let read_codec = ClientControlCodec::new();
let write_codec = ClientControlCodec::new();
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
crate::network_loop(username, event_rx, reader, writer).await
}
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
bail!("status not supported on desktop yet")
}
#[allow(unused)]
pub use tokio::spawn;
#[allow(unused)]
pub type SpawnHandle = tokio::runtime::Handle;
+299 -45
View File
@@ -1,59 +1,313 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use crate::effects::{AudioProcessor, AudioProcessorSender};
use color_eyre::eyre::{eyre, Context, Error};
use cpal::traits::{DeviceTrait, HostTrait};
use dioxus::hooks::UnboundedReceiver;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::collections::HashMap;
use std::time::Duration;
use futures::io::{AsyncRead, AsyncWrite};
use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::ClientConfig;
use std::mem::replace;
use std::net::ToSocketAddrs;
use std::sync::Arc;
use std::sync::Mutex;
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::DigitallySignedStruct;
use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{error, info, instrument, warn};
/// Desktop platform implementation using Tokio and native audio.
pub struct DesktopPlatform;
pub use tokio::runtime::Handle as SpawnHandle;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
impl super::PlatformInterface for DesktopPlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
type ConfigSystem = super::native_config::NativeConfigSystem;
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
Ok(ProxyOverrides {
proxy_url: None,
cert_hash: None,
any_server: true,
pub struct AudioSystem {
output: cpal::Device,
input: cpal::Device,
processors: AudioProcessorSender,
recording_stream: Option<cpal::Stream>,
}
const SAMPLE_RATE: u32 = 48_000;
const PACKET_SAMPLES: u32 = 960;
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
impl AudioSystem {
pub 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,
})
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
pub fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
pub fn start_recording(
&mut self,
mut each: impl FnMut(Vec<u8>) + Send + 'static,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, overrides).await
let mut encoder =
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
let mut current_processor = AudioProcessor::new_plain();
let mut output_buffer = Vec::new();
let processors = self.processors.clone();
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
let data_callback = move |frame: &[f32], _: &cpal::InputCallbackInfo| {
if let Some(new_processor) = processors.take() {
current_processor = new_processor;
}
current_processor.process(frame, &mut output_buffer);
if output_buffer.len() < PACKET_SAMPLES as usize {
return;
}
let remainder = output_buffer.split_off(PACKET_SAMPLES as usize);
let frame = replace(&mut output_buffer, remainder);
match encoder.encode_vec_float(&frame, frame.len() * 2) {
Ok(buf) => {
each(buf);
}
Err(e) => {
error!("error encoding {} samples: {e:?}", frame.len());
}
}
};
match self.input.build_input_stream(
&cpal::StreamConfig {
channels: 1,
sample_rate: cpal::SampleRate(SAMPLE_RATE),
buffer_size: cpal::BufferSize::Fixed(PACKET_SAMPLES),
},
data_callback,
error_callback,
None,
) {
Ok(stream) => {
self.recording_stream = Some(stream);
Ok(())
}
Err(err) => {
self.recording_stream = None;
Err(err.into())
}
}
}
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
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts(
0,
0,
vec![
0;
SAMPLE_RATE as usize/4 // 250ms of buffer
],
)));
let decoder = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Mono)?;
let stream = {
let buffer = buffer.clone();
self.output.build_output_stream(
&cpal::StreamConfig {
channels: 1,
sample_rate: cpal::SampleRate(SAMPLE_RATE),
buffer_size: cpal::BufferSize::Fixed(480), // 10ms playback delay
},
move |frame, info| {
let mut buffer = buffer.lock().unwrap();
for x in frame.iter_mut() {
match buffer.pop() {
Some(y) => {
*x = y;
}
None => {
*x = 0;
}
}
}
},
move |err| error!("could not create output stream {err:?}"),
None,
)?
};
Ok(AudioPlayer {
decoder,
stream,
buffer,
tmp: vec![0; 2400],
})
}
}
pub struct AudioPlayer {
decoder: opus::Decoder,
stream: cpal::Stream,
buffer: Buffer,
tmp: Vec<i16>,
}
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;
}
}
};
let mut buffer = self.buffer.lock().unwrap();
let mut overrun = 0;
for x in &self.tmp[..len] {
if let Some(_) = buffer.push(*x) {
overrun += 1;
}
}
if overrun > 0 {
warn!("playback overrun by {overrun} samples");
}
}
}
#[derive(Debug)]
struct NoCertificateVerification;
impl ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}
#[instrument]
pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
info!("connecting");
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let addr = format!("{}:{}", address, 64738)
.to_socket_addrs()?
.next()
.unwrap();
let server_tcp = TcpStream::connect(addr).await?;
let server_stream = connector
//.connect("127.0.0.1".try_into()?, server_tcp)
.connect(address.try_into()?, server_tcp)
.await?;
let (read_server, write_server) = tokio::io::split(server_stream);
let read_codec = ClientControlCodec::new();
let write_codec = ClientControlCodec::new();
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
crate::network_loop(username, event_rx, reader, writer).await
}
pub fn set_default_username(username: &str) -> Option<()> {
None
}
pub fn load_username() -> Option<String> {
return None;
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
status_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();
}
-75
View File
@@ -1,75 +0,0 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::time::Duration;
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
pub struct MobilePlatform;
impl super::PlatformInterface for MobilePlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
type ConfigSystem = super::native_config::NativeConfigSystem;
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
Ok(ProxyOverrides {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, overrides).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 -180
View File
@@ -1,185 +1,11 @@
//! 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, 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 {
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,
) -> 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(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 = "web")]
mod web;
#[cfg(feature = "desktop")]
mod desktop;
#[cfg(feature = "mobile")]
mod mobile;
#[cfg(all(feature = "web", not(feature = "desktop")))]
pub use web::*;
#[cfg(feature = "web")]
mod web;
// ============================================================================
// Platform Type Alias
// ============================================================================
#[cfg(feature = "web")]
pub type Platform = web::WebPlatform;
#[cfg(all(feature = "desktop", not(feature = "web")))]
pub type Platform = desktop::DesktopPlatform;
#[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(feature = "desktop")]
pub use desktop::*;
-219
View File
@@ -1,219 +0,0 @@
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{eyre, Error};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
use std::mem::replace;
use std::sync::Arc;
use std::sync::Mutex;
use tracing::{error, info, warn};
pub struct NativeAudioSystem {
output: cpal::Device,
input: cpal::Device,
processors: AudioProcessorSender,
recording_stream: Option<cpal::Stream>,
}
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,
output_buffer: &mut Vec<f32>,
encoder: &mut opus::Encoder,
each: &mut impl FnMut(Vec<u8>, bool),
) {
let (is_terminator, should_encode) = match state {
TransmitState::Silent => return,
TransmitState::Transmitting => (false, output_buffer.len() >= PACKET_SAMPLES as usize),
TransmitState::Terminator => {
output_buffer.resize(PACKET_SAMPLES as usize, 0.0);
(true, true)
}
};
if should_encode {
let remainder = output_buffer.split_off(PACKET_SAMPLES as usize);
let frame = replace(output_buffer, remainder);
match encoder.encode_vec_float(&frame, frame.len() * 2) {
Ok(encoded) => each(encoded, is_terminator),
Err(e) => error!("error encoding {} samples: {e:?}", frame.len()),
}
}
}
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
impl NativeAudioSystem {
fn choose_config(
&self,
configs: impl Iterator<Item = cpal::SupportedStreamConfigRange>,
) -> Result<cpal::StreamConfig, Error> {
let mut supported_configs: Vec<_> = configs
.filter_map(|cfg| cfg.try_with_sample_rate(cpal::SampleRate(SAMPLE_RATE)))
.filter(|cfg| cfg.sample_format() == cpal::SampleFormat::I16)
.map(|cfg| cpal::StreamConfig {
buffer_size: cpal::BufferSize::Fixed(match *cfg.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => 480.clamp(min, max),
cpal::SupportedBufferSize::Unknown => 480,
}),
..cfg.config()
})
.collect();
supported_configs.sort_by(|a, b| {
let cpal::BufferSize::Fixed(a_buf) = a.buffer_size else {
unreachable!()
};
let cpal::BufferSize::Fixed(b_buf) = b.buffer_size else {
unreachable!()
};
Ord::cmp(&a.channels, &b.channels).then(Ord::cmp(&a_buf, &b_buf))
});
supported_configs
.get(0)
.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(
&mut self,
mut each: impl FnMut(Vec<u8>, bool) + Send + 'static,
) -> Result<(), Error> {
let config = self.choose_config(self.input.supported_input_configs()?)?;
info!(
"creating recording on {:?} with {:#?}",
self.input.name()?,
config
);
let mut encoder =
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
let mut current_processor = AudioProcessor::new_plain();
let mut output_buffer = Vec::new();
let processors = self.processors.clone();
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
let data_callback = move |frame: &[f32], _: &cpal::InputCallbackInfo| {
if let Some(new_processor) = processors.take() {
current_processor = new_processor;
}
let state =
current_processor.process(frame, config.channels as usize, &mut output_buffer);
encode_and_send(state, &mut output_buffer, &mut encoder, &mut each);
};
match self
.input
.build_input_stream(&config, data_callback, error_callback, None)
{
Ok(stream) => {
stream.play()?;
self.recording_stream = Some(stream);
Ok(())
}
Err(err) => {
self.recording_stream = None;
Err(err.into())
}
}
}
fn create_player(&mut self) -> Result<NativeAudioPlayer, Error> {
let config = self.choose_config(self.output.supported_output_configs()?)?;
info!(
"creating player on {:?} with {:#?}",
self.output.name().ok(),
&config
);
let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts(
0,
0,
vec![
0;
SAMPLE_RATE as usize/4 // 250ms of buffer
],
)));
let decoder = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Mono)?;
let stream = {
let buffer = buffer.clone();
self.output.build_output_stream(
&config,
move |frame, _info| {
let mut buffer = buffer.lock().unwrap();
for x in frame.chunks_mut(config.channels as usize) {
match buffer.pop() {
Some(y) => {
x.fill(y);
}
None => {
x.fill(0);
}
}
}
},
move |err| error!("could not create output stream {err:?}"),
None,
)?
};
stream.play()?;
Ok(NativeAudioPlayer {
decoder,
stream,
buffer,
tmp: vec![0; MAX_DECODE_SAMPLES],
})
}
}
pub struct NativeAudioPlayer {
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;
}
};
let mut buffer = self.buffer.lock().unwrap();
let mut overrun = 0;
for x in &self.tmp[..len] {
if let Some(_) = buffer.push(*x) {
overrun += 1;
}
}
if overrun > 0 {
warn!("playback overrun by {overrun} samples");
}
}
}
-121
View File
@@ -1,121 +0,0 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::ServerStatus;
use std::collections::HashMap;
use std::time::Duration;
use tracing::{error, info, warn};
#[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()
}
-126
View File
@@ -1,126 +0,0 @@
/// Stub implementation of the platform interface, so that we can
/// `cargo check` without any --feature flags.
use crate::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,
) -> 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")
}
}
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")
}
+145 -244
View File
@@ -1,22 +1,21 @@
use crate::app::Command;
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use crate::effects::{AudioProcessor, AudioProcessorSender};
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;
use reqwest::Url;
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use tracing::level_filters::LevelFilter;
use tracing::{debug, error, info, instrument};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::js_sys::{Promise, Reflect, Uint8Array};
use web_sys::AudioContext;
use web_sys::AudioContextOptions;
use web_sys::AudioData;
use web_sys::AudioDecoder;
@@ -29,104 +28,27 @@ 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::MediaStreamTrackGenerator;
use web_sys::MediaStreamTrackGeneratorInit;
use web_sys::MessageEvent;
use web_sys::WebTransport;
use web_sys::WebTransportBidirectionalStream;
use web_sys::WebTransportOptions;
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,
) -> Result<(), Error> {
network_connect(address, username, event_rx, overrides).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> {
@@ -151,56 +73,25 @@ impl<T> ResultExt<T> for Result<T, JsError> {
}
}
pub struct WebAudioSystem {
pub struct AudioSystem {
webctx: AudioContext,
processors: AudioProcessorSender,
}
async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
// Create worklets to process mic and speaker audio
// Speaker audio processing worklet only required on
// browsers that don't support MediaStreamTrackGenerator
let options = WorkletOptions::new();
Reflect::set(
&options,
&"processorOptions".into(),
&wasm_bindgen::module(),
)
.ey()?;
let module = asset!("assets/rust_audio_worklet.js").to_string();
info!("loading mic worklet from {module:?}");
audio_context
.audio_worklet()
.ey()?
.add_module_with_options(&module, &options)
.ey()?
.into_future()
.await
.ey()?;
Ok(())
}
impl super::AudioSystemInterface for WebAudioSystem {
type AudioPlayer = WebAudioPlayer;
async fn new() -> Result<Self, Error> {
impl AudioSystem {
pub fn new() -> Result<Self, Error> {
// Create MediaStreams to playback decoded audio
// The audio context is used to reproduce audio.
let webctx = configure_audio_context();
attach_worklet(&webctx).await?;
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>) + 'static) -> Result<(), Error> {
let audio_context_worklet = self.webctx.clone();
let processors = self.processors.clone();
spawn(async move {
@@ -212,11 +103,19 @@ impl super::AudioSystemInterface for WebAudioSystem {
Ok(())
}
fn create_player(&mut self) -> Result<WebAudioPlayer, Error> {
let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?;
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let audio_stream_generator =
MediaStreamTrackGenerator::new(&MediaStreamTrackGeneratorInit::new("audio")).ey()?;
// Connect worklet to destination
sink_node
// Create MediaStream from MediaStreamTrackGenerator
let js_tracks = web_sys::js_sys::Array::new();
js_tracks.push(&audio_stream_generator);
let media_stream = MediaStream::new_with_tracks(&js_tracks).ey()?;
// Create MediaStreamAudioSourceNode
let audio_source = self.webctx.create_media_stream_source(&media_stream).ey()?;
// Connect output of audio_source to audio_context (browser audio)
audio_source
.connect_with_audio_node(&self.webctx.destination())
.ey()?;
@@ -225,31 +124,28 @@ impl super::AudioSystemInterface for WebAudioSystem {
error!("error decoding audio {:?}", e);
}) as Box<dyn FnMut(JsValue)>);
let sink_port = sink_node.port().ey()?;
// This knows what MediaStreamTrackGenerator to use as it closes around it
let output = Closure::wrap(Box::new(move |audio_data: AudioData| {
// Extract planar PCM from AudioData into an ArrayBuffer or Float32Array
// Here we assume f32 samples, 1 channel for brevity.
let number_of_frames = audio_data.number_of_frames();
let js_buffer = Float32Array::new_with_length(number_of_frames);
let audio_data_copy_to_options = &AudioDataCopyToOptions::new(0);
audio_data_copy_to_options.set_format(web_sys::AudioSampleFormat::F32);
if let Err(e) = audio_data
.copy_to_with_buffer_source(&js_buffer.buffer(), &audio_data_copy_to_options)
{
error!("could not copy audio data to array {:?}", e);
let writable = audio_stream_generator.writable();
if writable.locked() {
return;
}
if let Err(e) = writable.get_writer().map(|writer| {
spawn(async move {
if let Err(e) = JsFuture::from(writer.ready()).await.ey() {
error!("write chunk ready error {:?}", e);
}
if let Err(e) = JsFuture::from(writer.write_with_chunk(&audio_data))
.await
.ey()
{
error!("write chunk error {:?}", e);
};
writer.release_lock();
});
}) {
error!("error writing audio data {:?}", e);
}
// Post to the worklet; include sampleRate and channel count if needed.
let msg = js_sys::Object::new();
js_sys::Reflect::set(&msg, &"samples".into(), &js_buffer).unwrap();
sink_port.post_message(&msg).unwrap();
audio_data.close();
}) as Box<dyn FnMut(AudioData)>);
let audio_decoder = AudioDecoder::new(&AudioDecoderInit::new(
@@ -265,14 +161,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(
@@ -304,26 +200,22 @@ impl PromiseExt for Promise {
}
}
fn process_audio(frame: &JsValue, processor: &mut AudioProcessor) -> TransmitState {
fn process_audio(frame: &JsValue, processor: &mut AudioProcessor) {
let Ok(samples) = Reflect::get(&frame, &"data".into()) else {
return TransmitState::Silent;
return;
};
let Ok(samples) = samples.dyn_into::<Float32Array>() else {
return TransmitState::Silent;
return;
};
let input = samples.to_vec();
let mut output = Vec::with_capacity(input.len());
let state = processor.process(&input, 1, &mut output);
if !output.is_empty() {
samples.copy_from(&output);
}
state
processor.process(&input, &mut output);
samples.copy_from(&output);
}
async fn run_encoder_worklet(
audio_context: &AudioContext,
mut each: impl FnMut(Vec<u8>, bool) + 'static,
mut each: impl FnMut(Vec<u8>) + 'static,
processors: AudioProcessorSender,
) -> Result<AudioWorkletNode, Error> {
let constraints = MediaStreamConstraints::new();
@@ -342,25 +234,37 @@ async fn run_encoder_worklet(
.map_err(|e| JsError::new(&format!("not a stream: {e:?}")))
.ey()?;
let options = WorkletOptions::new();
Reflect::set(
&options,
&"processorOptions".into(),
&wasm_bindgen::module(),
)
.ey()?;
let module = asset!("assets/rust_mic_worklet.js").to_string();
info!("loading mic worklet from {module:?}");
audio_context
.audio_worklet()
.ey()?
.add_module_with_options(&module, &options)
.ey()?
.into_future()
.await
.ey()?;
let source = audio_context.create_media_stream_source(&stream).ey()?;
let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet").ey()?;
let encoder_error: Closure<dyn FnMut(JsValue)> =
Closure::new(|e| error!("error encoding audio {:?}", e));
// Shared state to signal terminator between onmessage and output closures
// The output closure runs asynchronously after encoding completes
let pending_terminator = Arc::new(AtomicCell::new(false));
let pending_terminator_output = pending_terminator.clone();
// This knows what MediaStreamTrackGenerator to use as it closes around it
let output: Closure<dyn FnMut(EncodedAudioChunk)> =
Closure::new(move |audio_data: EncodedAudioChunk| {
let mut array = vec![0u8; audio_data.byte_length() as usize];
audio_data.copy_to_with_u8_slice(&mut array);
// Check if this frame was marked as a terminator
let is_terminator = pending_terminator_output.swap(false);
each(array, is_terminator);
each(array);
});
let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new(
@@ -387,19 +291,8 @@ async fn run_encoder_worklet(
}
let frame = event.data();
let state = process_audio(&frame, &mut current_processor);
process_audio(&frame, &mut current_processor);
match state {
TransmitState::Silent => {
// Don't encode or send anything
return;
}
TransmitState::Transmitting => (), // Normal transmission
TransmitState::Terminator => {
// Mark this as a terminator before encoding
pending_terminator.store(true);
}
}
match AudioData::new(frame.unchecked_ref()) {
Ok(data) => {
let _ = audio_encoder.encode(&data);
@@ -433,7 +326,7 @@ pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
gui_config: &ClientConfig,
) -> Result<(), Error> {
info!("connecting");
@@ -446,7 +339,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()?;
}
@@ -495,69 +388,77 @@ pub async fn network_connect(
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 load_username() -> Option<String> {
web_sys::window()
.unwrap()
.local_storage()
.ok()??
.get_item("username")
.ok()?
}
pub fn absolute_url(path: &str) -> Result<Url, Error> {
let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
let location = window.location();
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 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()
}
+47 -27
View File
@@ -5,17 +5,13 @@ use app::STATE;
use asynchronous_codec::FramedRead;
use asynchronous_codec::FramedWrite;
use color_eyre::eyre::{bail, Error};
#[cfg(feature = "blitz")]
use dioxus_native::prelude::*;
#[cfg(not(feature = "blitz"))]
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;
@@ -24,17 +20,17 @@ use mumble_protocol::voice::VoicePacket;
use mumble_protocol::voice::VoicePacketPayload;
use mumble_protocol::Clientbound;
use mumble_protocol::Serverbound;
use mumble_web2_common::ClientConfig;
use once_cell::sync::Lazy;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::time::Duration;
use tracing::debug;
use tracing::error;
use tracing::info;
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;
@@ -54,9 +50,7 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
*STATE.server.write() = Default::default();
*STATE.status.write() = ConnectionState::Connecting;
if let Err(error) =
Platform::network_connect(address, username, &mut event_rx, &config).await
{
if let Err(error) = imp::network_connect(address, username, &mut event_rx, &config).await {
error!("could not connect {:?}", error);
*STATE.status.write() = ConnectionState::Failed(error.to_string());
} else {
@@ -65,7 +59,7 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
}
}
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,
event_rx: &mut UnboundedReceiver<Command>,
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
@@ -114,23 +108,23 @@ 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?;
let mut audio = imp::AudioSystem::new()?;
{
let send_chan = send_chan.clone();
let mut sequence_num = 0;
audio.start_recording(move |opus_frame, is_terminator| {
audio.start_recording(move |opus_frame| {
let _ =
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
_dst: std::marker::PhantomData,
target: 0,
session_id: (),
seq_num: sequence_num,
payload: VoicePacketPayload::Opus(opus_frame.into(), is_terminator),
payload: VoicePacketPayload::Opus(opus_frame.into(), false),
position_info: None,
})));
sequence_num = sequence_num.wrapping_add(2);
@@ -305,8 +299,8 @@ fn accept_command(
fn accept_packet(
msg: ControlPacket<mumble_protocol::Clientbound>,
audio_context: &mut AudioSystem,
player_map: &mut HashMap<u32, AudioPlayer>,
audio_context: &mut imp::AudioSystem,
player_map: &mut HashMap<u32, imp::AudioPlayer>,
) -> Result<(), Error> {
match msg {
ControlPacket::UDPTunnel(u) => {
@@ -344,11 +338,41 @@ fn accept_packet(
}
ControlPacket::ChannelState(u) => {
let mut server = STATE.server.write();
server.channels_state.update_from_channel_state(&u);
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();
server.channels_state.update_from_channel_remove(&u);
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();
@@ -360,13 +384,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()
@@ -384,9 +407,6 @@ fn accept_packet(
if u.has_deaf() {
state.deaf = u.get_deaf();
}
if u.has_suppress() {
state.suppress = u.get_suppress();
}
if u.has_self_mute() {
state.self_mute = u.get_self_mute();
}
@@ -398,7 +418,7 @@ fn accept_packet(
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);
}
}
+2 -5
View File
@@ -1,9 +1,6 @@
use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
use mumble_web2_gui::{app, imp};
pub fn main() {
Platform::init_logging();
#[cfg(feature = "blitz")]
dioxus_native::launch(app::app);
#[cfg(not(feature = "blitz"))]
imp::init_logging();
dioxus::launch(app::app);
}
+33 -18
View File
@@ -1,6 +1,10 @@
use color_eyre::eyre::{anyhow, bail, Context, Result};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use color_eyre::owo_colors::OwoColorize;
use mumble_web2_common::{ClientConfig, ServerStatus};
use once_cell::sync::OnceCell;
use rand::Rng;
use rcgen::date_time_ymd;
use rustls::server;
use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::cors::{AllowOrigin, Cors};
use salvo::logging::Logger;
@@ -16,7 +20,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;
@@ -34,6 +38,7 @@ fn default_cert_alt_names() -> Vec<String> {
#[derive(Debug, Deserialize, Serialize)]
struct Config {
public_url: Url,
proxy_url: Option<Url>,
https_listen_address: SocketAddr,
http_listen_address: Option<SocketAddr>,
@@ -77,11 +82,12 @@ 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,
None => Some(server_config.public_url.join("proxy")?.to_string()),
},
status_url: Some(server_config.public_url.join("status")?.to_string()),
cert_hash: None,
any_server: false,
};
@@ -102,7 +108,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 +128,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 +145,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 +258,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 +326,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();
@@ -332,13 +341,19 @@ async fn connect_proxy_impl(
info!("connected to Mumble server");
// Handle transmitting data between the WebTransport client and Mumble TCP Server
// When one direction completes/fails, the other is dropped and its streams are closed
// Spawn tasks to handle transmitting data between the WebTransport client and Mumble TCP Server
let c2s = tokio::spawn(
pass_bytes_loop(incoming, write_server)
.instrument(info_span!("Handler", "Client to server")),
);
let s2c = tokio::spawn(
pass_bytes_loop(read_server, outgoing)
.instrument(info_span!("Handler", "Server to client")),
);
tokio::select! {
res = pass_bytes_loop(incoming, write_server)
.instrument(info_span!("Handler", "Client to server")) => res?,
res = pass_bytes_loop(read_server, outgoing)
.instrument(info_span!("Handler", "Server to client")) => res?,
res = c2s => res??,
res = s2c => res??,
};
Ok(())
}