16 Commits

Author SHA1 Message Date
restitux aac401e841 Add blitz feature for Dioxus Native rendering support
Build Mumble Web 2 / linux_build (push) Failing after 1m56s
Build Mumble Web 2 / macos_build (push) Successful in 3m24s
Build Mumble Web 2 / windows_build (push) Successful in 13m11s
Build Mumble Web 2 / android_build (push) Successful in 12m54s
Add an orthogonal `blitz` feature flag that switches the rendering
framework from dioxus webview to dioxus-native (Blitz). Can be combined
with `desktop` or `mobile` features. Also bumps rfd to ^0.17.0 (upstream
git moved forward), removes stale gui-level [patch.crates-io] section,
and adds xdotool to the Dockerfile for libxdo (blitz-shell dependency).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 19:40:18 -06:00
restitux d67a19c478 Create new generic config abstraction (#25)
Build Mumble Web 2 / macos_build (push) Successful in 55s
Build Mumble Web 2 / linux_build (push) Successful in 1m18s
Build Mumble Web 2 / android_build (push) Successful in 5m36s
Build Mumble Web 2 / windows_build (push) Successful in 8m4s
Build android container / android-release-builder-container-build (push) Successful in 7s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
This change migrates the config logic to a new generic key+value abstraction. This allows config parameters to be get and set with arbitrary string keys. Config value types can be anything that serde knows how to serialize / deserialize.

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

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

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

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

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

The TLDR version of this new trait stuff works:

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

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

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

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

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

#  Motivation

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

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

# Changes

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

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

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

# Testing

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

Co-authored-by: Liam Warfield <liam.warfield@gmail.com>
Reviewed-on: #18
Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2026-02-18 04:53:41 +00:00
liamwarfield 083a11274e Fix buffer bug in native audio.
Build Mumble Web 2 / linux_build (push) Successful in 5m5s
Build Mumble Web 2 / windows_build (push) Successful in 9m48s
Build Mumble Web 2 / android_build (push) Successful in 12m5s
Build android container / android-release-builder-container-build (push) Successful in 1s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 13s
Setting this buffer to 2400 was only enough to store 50m of sound
at 48,000 samples/sec. I've also done a small refactor here to
make this buffer scale with sample rate.
2026-02-06 17:47:55 -07:00
restitux 2fcb853c30 gui: improve channel selection behavior (#21)
Build Mumble Web 2 / windows_build (push) Successful in 2m41s
Build Mumble Web 2 / linux_build (push) Successful in 1m25s
Build Mumble Web 2 / android_build (push) Successful in 5m58s
Build android container / android-release-builder-container-build (push) Successful in 9s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 13s
# Summary
This change improves the channel selection behavior to be more similar to the official client and generally more usable. It's currently mildly broken due to the details element grabbing click events from the whole row and the row text being selectable. This change also makes it more obvious that the channel title can be clicked. I'm not sure how this works on mobile, so we might need to make more changes there in the future to work better with touchscreens.

# Changes
- Channels can only be expanded or collapsed by clicking on the adjacent arrow
- Expand/collapse arrows are only displayed on channels with children or users
- Channel can only be joined by double clicking to the right of the collapse/expand arrow
- The channel title background (and the empty space to the right) display a highlight when the user hovers over them.
- All text inside the channel view is no longer selectable.

# Testing
I tested on the desktop client. I didn't test on mobile but I'll give it a shot after I merge and maybe come back with another PR to make this behavior more intuitive over there.

Reviewed-on: #21
2026-01-28 06:03:23 +00:00
restitux feaa9f2bda gui: fix channel sort order (#17) (#20)
Build Mumble Web 2 / linux_build (push) Successful in 1m28s
Build Mumble Web 2 / windows_build (push) Successful in 2m44s
Build Mumble Web 2 / android_build (push) Successful in 5m56s
Build android container / android-release-builder-container-build (push) Successful in 7s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
# Summary
Channel ordering is currently broken as described in #17.  This change makes sorting work correctly and cleans up the logic a bit.

# Changes
- creates a `ChannelsState` wrapper struct to handle this behavior
- moves the logic for handling `ChannelState` processing, including data update and parent-child tree sorting, into the impl for `ChannelsState`
- moves the logic for handling `ChannelRemove` into this impl

Parent child sorting properly applies the position values, which are arbitrary integers that are supposed to be sorted in numerical order. Lexicographical sorting is use for tiebreaking, which lines up (at least in my testing) with the official client's behavior. We may handle some lexicographical edge cases differently (spaces, symbols, etc) but 1. the Desktop client compliance is best effort and 2. users should use the position fields instead of relying on text sort order. Some compatibility is still helpful for matching temporary channel positioning, especially for servers with automated channel creation workflows.

This code is a bit complicated, as the mumble protocol makes no guarantees which order the channels will be sent. It ended up being simpler to just bulk recreate the children anytime any channel update is sent. I don't expect this to ever have performance issues, though maybe someday some server with 10,000 channels will send us a bug report 😆

# Testing
I tested this change by creating a bunch of channel with various sort orders and names. I compared the behavior with the official desktop client and our client seemed to follow along.

Reviewed-on: #20
2026-01-25 19:10:17 +00:00
restitux aa3fcf09cf meta: update dioxus cli to 0.7.3 (#19)
Build Mumble Web 2 / linux_build (push) Successful in 1m26s
Build Mumble Web 2 / windows_build (push) Successful in 2m37s
Build Mumble Web 2 / android_build (push) Successful in 5m54s
Reviewed-on: #19
Reviewed-by: Sam Sartor <cap@samsartor.com>
2026-01-25 06:25:21 +00:00
liamwarfield a30082eebe Fix Proxy slow disconnect.
Build android container / android-release-builder-container-build (push) Successful in 10s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 13s
Build Mumble Web 2 / linux_build (push) Successful in 1m25s
Build Mumble Web 2 / windows_build (push) Successful in 2m37s
Build Mumble Web 2 / android_build (push) Successful in 5m57s
When `select!` drops a JoinHandle, it doesn't abort the spawned task - it detaches it. From tokio docs:

A JoinHandle detaches the associated task when it is dropped, which means that there is no
longer any handle to the task, and no way to join on it.

This means the spawn task is still spinning in the background:
  1. c2s completes (client disconnected)
  2. select! drops the s2c JoinHandle
  3. The s2c task continues running in the background, detached
  4. That task holds the Mumble server connection open

The fix is to hand the async calls directly to select! instead of
calling tokio::spawn.
2026-01-19 19:17:23 -07:00
liamwarfield 7c75e64a64 Add SELinux :z labels to docker-compose volume mounts
Build Mumble Web 2 / linux_build (push) Successful in 1m29s
Build Mumble Web 2 / windows_build (push) Successful in 2m40s
Build Mumble Web 2 / android_build (push) Successful in 5m57s
Enables volume access on SELinux-enabled systems like Fedora by
applying shared labels to the mounted paths.
2026-01-19 15:08:38 -07:00
liamwarfield 65883917b0 Add a default noise floor. (#13)
Build Mumble Web 2 / linux_build (push) Successful in 1m29s
Build Mumble Web 2 / android_build (push) Has been cancelled
Build Mumble Web 2 / windows_build (push) Has been cancelled
(Turns out not) Pretty simple, if the average amplitude is under a certain value clear
out the buffer! The value I chose (.001) was an arbitrary value I got
from printf debugging. I was able to show that this worked pretty well
on the desktop session. Hopefully we'll add this to the settings page at
some point.

Once the app has been under that threshold for more than 200ms, we stop transmitting and send a terminator packet.

Reviewed-on: #13
Reviewed-by: restitux <restitux@ohea.xyz>
2026-01-19 22:07:06 +00:00
liamwarfield c8119d0efa The client now handles being suppressed correctly (#16)
Build Mumble Web 2 / linux_build (push) Successful in 1m25s
Build Mumble Web 2 / windows_build (push) Successful in 2m38s
Build Mumble Web 2 / android_build (push) Successful in 5m56s
I noticed our BRB and AFK rooms were borked and made a change to fix that. I've attached a photo showing the result.

Overview:

- Add suppress field to UserState struct
- Process suppress field from UserState protobuf messages
- Update UI to show suppressed users with blacked out styling and muted icon
- Disable mute toggle button when user is suppressed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Reviewed-on: #16
2026-01-19 19:12:57 +00:00
restitux d7b88874df android builds (#9)
Build Mumble Web 2 / linux_build (push) Successful in 1m31s
Build Mumble Web 2 / windows_build (push) Successful in 2m39s
Build Mumble Web 2 / android_build (push) Successful in 5m54s
Build android container / android-release-builder-container-build (push) Successful in 5s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 16s
This adds android builds to the CI infrastructure. These builds generate an `apk` file that you can download and install.
- Adds a new container build job that builds a container with all the required android dependencies
- Adds a new release build that builds an android apk
- Updated the imp module to split out mobile and desktop behavior
- Adds logic to request microphone permissions
- Added a custom android manifest that declares the required permissions

Reviewed-on: #9
2026-01-19 01:03:45 +00:00
samuel127849 f001a192e1 UI scaling (#15)
Build Mumble Web 2 / linux_build (push) Successful in 1m31s
Build Mumble Web 2 / windows_build (push) Successful in 2m26s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
I vibe coded a change so that I can use mumble-web2 when I only have it in a small area instead of a full screen.

Scaling for smol screens:
![image.png](/attachments/7187de3c-cfce-4a30-891d-6a1b85096021)

Full screen scaling:
![image.png](/attachments/a1edc925-fb29-4dc7-ab45-51795d0738ad)

Reviewed-on: #15
Reviewed-by: restitux <restitux@ohea.xyz>
Co-authored-by: Samuel Warfield <samuel.warfield2@gmail.com>
Co-committed-by: Samuel Warfield <samuel.warfield2@gmail.com>
2026-01-13 18:24:41 +00:00
samuel127849 37c0bce57e Fix typo
Build Mumble Web 2 / linux_build (push) Successful in 1m41s
Build Mumble Web 2 / windows_build (push) Successful in 2m24s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 13s
2026-01-10 21:01:29 -07:00
41 changed files with 6346 additions and 1606 deletions
+1
View File
@@ -0,0 +1 @@
target
@@ -0,0 +1,27 @@
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
+65 -1
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 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 - name: Install dioxus-cli
run: cargo binstall dioxus-cli --version 0.7.2 run: cargo binstall dioxus-cli --version 0.7.3
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
@@ -42,6 +42,47 @@ jobs:
path: target/release/mumble-web2-proxy path: target/release/mumble-web2-proxy
retention-days: 5 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: windows_build:
runs-on: windows runs-on: windows
steps: steps:
@@ -83,3 +124,26 @@ jobs:
name: mumble-web2-gui-windows name: mumble-web2-gui-windows
path: gui/dist path: gui/dist
retention-days: 5 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
Generated
+4353 -856
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ClientConfig { pub struct ProxyOverrides {
pub proxy_url: Option<String>, pub proxy_url: Option<String>,
pub cert_hash: Option<Vec<u8>>, pub cert_hash: Option<Vec<u8>>,
pub any_server: bool, pub any_server: bool,
+1 -3
View File
@@ -2,13 +2,11 @@ localhost:64444 {
tls internal tls internal
# Proxy /config path to mumble-web2-proxy # Proxy /config path to mumble-web2-proxy
reverse_proxy /config http://127.0.0.1:4400 reverse_proxy /overrides http://127.0.0.1:4400
# Proxy /status path to mumble-web2-proxy # Proxy /status path to mumble-web2-proxy
reverse_proxy /status http://127.0.0.1:4400 reverse_proxy /status http://127.0.0.1:4400
# Proxy root path to dx-serve # Proxy root path to dx-serve
reverse_proxy http://127.0.0.1:8080 reverse_proxy http://127.0.0.1:8080
} }
+43
View File
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,33 @@
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/tcp"
- "64444:64444/udp" - "64444:64444/udp"
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile - ./Caddyfile:/etc/caddy/Caddyfile:z
#- caddy_data:/data #- caddy_data:/data
#- caddy_config:/config #- caddy_config:/config
depends_on: depends_on:
@@ -20,7 +20,7 @@ services:
# volumes: # volumes:
# - ..:/app # - ..:/app
# environment: # environment:
# - MUMBLE_WEB2_GUI_CONFIG_URL=https://localhost:64444/config # - MUMBLE_WEB2_PROXY_OVERRIDES_URL=https://localhost:64444/overrides
# stdin_open: true # stdin_open: true
# tty: true # tty: true
# command: > # command: >
@@ -35,8 +35,8 @@ services:
image: rust:latest image: rust:latest
working_dir: /app working_dir: /app
volumes: volumes:
- ..:/app - ..:/app:z
- ./proxy-config.toml:/app/config.toml - ./proxy-config.toml:/app/config.toml:z
ports: ports:
- "4433:4433/tcp" - "4433:4433/tcp"
- "4433:4433/udp" - "4433:4433/udp"
+3 -5
View File
@@ -44,14 +44,12 @@ RUN choco install rustup.install -y --no-progress
RUN rustup toolchain install stable-x86_64-pc-windows-msvc RUN rustup toolchain install stable-x86_64-pc-windows-msvc
RUN rustup default stable-x86_64-pc-windows-msvc RUN rustup default stable-x86_64-pc-windows-msvc
# Install carog binstall # Install cargo binstall
RUN Set-ExecutionPolicy Unrestricted -Scope Process; ` 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 (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"] SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
# Install dioxus-cli from git HEAD with cargo # Install dioxus-cli
# This is to work around a bug in the windows builder upstream. RUN cargo binstall dioxus-cli@0.7.3
# Dioxus has released 0.7.2, but it seems to be broken for now.
RUN cargo binstall dioxus-cli
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"] ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
+24 -6
View File
@@ -68,6 +68,7 @@ etcetera = { version = "0.10.0", optional = true }
# Base Dependencies # Base Dependencies
# ================ # ================
dioxus = { version = "0.7.2" } dioxus = { version = "0.7.2" }
dioxus-native = { git = "https://github.com/DioxusLabs/blitz", rev = "e64a3d8", features = ["prelude"], optional = true }
once_cell = "1.19.0" once_cell = "1.19.0"
asynchronous-codec = { workspace = true } asynchronous-codec = { workspace = true }
futures = "^0.3.30" futures = "^0.3.30"
@@ -88,13 +89,13 @@ tracing = "^0.1.40"
color-eyre = "^0.6.3" color-eyre = "^0.6.3"
crossbeam-queue = "^0.3.11" crossbeam-queue = "^0.3.11"
lol_html = "^2.2.0" lol_html = "^2.2.0"
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false }
base64 = "^0.22" base64 = "^0.22"
mime_guess = "^2.0.5" mime_guess = "^2.0.5"
async_cell = "^0.2.3" async_cell = "^0.2.3"
reqwest = { version = "^0.12.22", features = ["json"] } reqwest = { version = "^0.12.22", features = ["json"] }
dioxus-asset-resolver = "0.7.2" dioxus-asset-resolver = "0.7.2"
# Denoising # Denoising
# ========= # =========
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [ deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
@@ -102,11 +103,17 @@ deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d3
] } ] }
crossbeam = "0.8.4" crossbeam = "0.8.4"
[patch.crates-io] # Platform Integration
tract-hir = "=0.12.4" # ====================
tract-core = "=0.12.4" # rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS
tract-onnx = "=0.12.4" [target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
tract-pulse = "=0.12.4" 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"
[features] [features]
web = [ web = [
@@ -121,6 +128,7 @@ web = [
"gloo-timers", "gloo-timers",
"tracing-web", "tracing-web",
"deep_filter/wasm", "deep_filter/wasm",
"rfd",
] ]
desktop = [ desktop = [
"dioxus/desktop", "dioxus/desktop",
@@ -133,3 +141,13 @@ desktop = [
"rfd/xdg-portal", "rfd/xdg-portal",
"etcetera", "etcetera",
] ]
mobile = [
"dioxus/mobile",
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
]
blitz = ["dioxus-native"]
+3 -1
View File
@@ -8,6 +8,8 @@ out_dir = "dist"
# resource (public) file folder # resource (public) file folder
asset_dir = "public" asset_dir = "public"
android_manifest = "build/AndroidManifest.xml"
[web.app] [web.app]
# HTML title tag content # HTML title tag content
title = "Mumble Web 2" title = "Mumble Web 2"
@@ -33,7 +35,7 @@ style = []
script = [] script = []
[bundle] [bundle]
identifier = "xyz.ohea.mumble-web-2" identifier = "xyz.ohea.mumble_web_2"
publisher = "OheaCorp" publisher = "OheaCorp"
icon = [ icon = [
"icons/32x32.png", "icons/32x32.png",
+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 453 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 431 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 392 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 187 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 342 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 444 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 480 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 149 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 161 B

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 149 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 771 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 492 B

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 329 B

+152 -16
View File
@@ -83,6 +83,44 @@ 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 { .channel {
&_details { &_details {
flex: 0 0 100%; flex: 0 0 100%;
@@ -135,6 +173,7 @@ a:visited {
&_box { &_box {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
gap: 16px; gap: 16px;
background-color: var(--light-bg-color); background-color: var(--light-bg-color);
@@ -147,6 +186,12 @@ a:visited {
border-radius: 8px; border-radius: 8px;
.material-symbols-outlined {
width: 35px;
height: 35px;
cursor: pointer;
}
input { input {
color: white; color: white;
background-color: var(--light-bg-color); background-color: var(--light-bg-color);
@@ -168,40 +213,78 @@ a:visited {
background-color: oklch(0.53 0.1431 264.18); background-color: oklch(0.53 0.1431 264.18);
border-radius: 50%; border-radius: 50%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(4px, 0.5vw, 8px);
} }
.button_row { .button_row {
display: flex; display: flex;
gap: 10px; gap: clamp(4px, 1vw, 10px);
align-items: center;
flex-wrap: nowrap;
min-height: 0;
.spacer { .spacer {
flex-grow: 1; 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 { .toggle_button {
padding: 8px; padding: clamp(4px, 0.5vw, 8px);
height: 100%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
flex-shrink: 0;
background-color: unset; background-color: unset;
border: solid rgb(255 255 255 / 0.1) 3px; border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px);
border-radius: 10px; border-radius: clamp(4px, 0.8vw, 10px);
color: rgb(255 255 255 / 50%);
transition: all 0.5s ease-in-out; transition: all 0.5s ease-in-out;
&.is_on { &.is_on {
background-color: oklch(0.5 0.1381 21.71 / 20.12%); background-color: oklch(0.5 0.1381 21.71 / 20.12%);
color: oklch(0.53 0.1505 21.71 / 89.38%);
} }
.material-symbols-outlined { display: flex;
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; align-items: center;
vertical-align: middle; justify-content: center;
font-size: 35px;
}
} }
.server { .server {
@@ -245,16 +328,69 @@ a:visited {
} }
&_control_box { &_control_box {
padding: 16px; padding: clamp(6px, 0.8vw, 12px);
margin: 16px; margin: clamp(6px, 0.8vw, 12px);
background-color: var(--light-bg-color); background-color: var(--light-bg-color);
border-radius: 10px; border-radius: clamp(6px, 1vw, 10px);
overflow: hidden; overflow: hidden;
grid-area: control; grid-area: control;
display: flex; display: flex;
gap: 10px; gap: clamp(4px, 0.8vw, 8px);
flex-direction: column; 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;
}
} }
} }
+32
View File
@@ -0,0 +1,32 @@
<?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>
+300 -124
View File
@@ -1,12 +1,72 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
#[cfg(feature = "blitz")]
use dioxus_native::prelude::*;
#[cfg(not(feature = "blitz"))]
use dioxus::prelude::*; use dioxus::prelude::*;
use mime_guess::Mime; use mime_guess::Mime;
use mumble_web2_common::{ClientConfig, ServerStatus}; use mumble_web2_common::{ProxyOverrides, ServerStatus};
use ordermap::OrderSet; use ordermap::OrderSet;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use crate::imp; 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}",
})
}
pub type ChannelId = u32; pub type ChannelId = u32;
pub type UserId = u32; pub type UserId = u32;
@@ -23,7 +83,7 @@ pub enum Command {
Connect { Connect {
address: String, address: String,
username: String, username: String,
config: ClientConfig, config: ProxyOverrides,
}, },
SendChat { SendChat {
markdown: String, markdown: String,
@@ -54,30 +114,27 @@ pub enum Command {
use Command::*; use Command::*;
use ConnectionState::*; use ConnectionState::*;
#[derive(Default)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
}
#[derive(Default)] #[derive(Default)]
pub struct UserState { pub struct UserState {
pub name: String, pub name: String,
pub channel: ChannelId, pub channel: ChannelId,
pub deaf: bool, pub deaf: bool,
pub mute: bool, pub mute: bool,
pub suppress: bool,
pub self_deaf: bool, pub self_deaf: bool,
pub self_mute: bool, pub self_mute: bool,
} }
impl UserState { impl UserState {
pub fn icon(&self) -> UserIcon { pub fn icon(&self) -> UserIcon {
match (self.mute || self.self_mute, self.deaf || self.self_deaf) { if self.deaf || self.self_deaf {
(false, false) => UserIcon::Normal, UserIcon::Deafened
(true, false) => UserIcon::Muted, } else if self.mute || self.self_mute {
(_, true) => UserIcon::Deafened, UserIcon::Muted
} else if self.suppress {
UserIcon::Suppressed
} else {
UserIcon::Normal
} }
} }
} }
@@ -89,8 +146,121 @@ pub struct Chat {
} }
#[derive(Default)] #[derive(Default)]
pub struct ServerState { 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>, pub channels: HashMap<ChannelId, ChannelState>,
}
impl ChannelsState {
pub fn update_from_channel_state(
&mut self,
channel_state: &mumble_protocol::control::msgs::ChannelState,
) {
self.channels
.entry(channel_state.get_channel_id())
.or_default()
.update_from_channel_state(channel_state);
self.update_channel_parents();
}
pub fn update_from_channel_remove(
&mut self,
channel_remove: &mumble_protocol::control::msgs::ChannelRemove,
) {
self.channels.remove(&channel_remove.get_channel_id());
self.update_channel_parents();
}
pub fn update_channel_parents(&mut self) {
// Zero out existing children
for state in self.channels.values_mut() {
state.children.clear();
}
let mut to_sort: Vec<(ChannelId, Option<ChannelId>, i32, String)> = Vec::new();
for (id, state) in self.channels.iter() {
// Handle channels with no parent (the root channel)
let Some(parent_id) = state.parent else {
to_sort.push((*id, None, 0, state.name.clone()));
continue;
};
// If a channel has a parent that we haven't gotten a channel
// state packet for, ignore it
if !self.channels.contains_key(&parent_id) {
continue;
}
to_sort.push((*id, Some(parent_id), state.position, state.name.clone()));
}
let pos_name: HashMap<ChannelId, (i32, String)> = self
.channels
.iter()
.map(|(&id, state)| (id, (state.position, state.name.clone())))
.collect();
let mut updated: HashSet<ChannelId> = HashSet::new();
while updated.len() < to_sort.len() {
for &(id, ref parent_id, position, ref name) in &to_sort {
let Some(parent_id) = parent_id else {
updated.insert(id);
continue;
};
if updated.contains(&id) || !updated.contains(&parent_id) {
continue;
}
// Unwrap should never fail here since we pre filter
let parent = self.channels.get_mut(&parent_id).unwrap();
let mut insert_index = parent.children.len();
for (i, &child) in parent.children.iter().enumerate() {
let (p, ref n) = pos_name[&child];
if (position == p && name < n) || p > position {
insert_index = i;
break;
}
}
parent.children.insert_before(insert_index, id);
updated.insert(id);
}
}
}
}
#[derive(Default)]
pub struct ServerState {
pub channels_state: ChannelsState,
pub users: HashMap<UserId, UserState>, pub users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>, pub chat: Vec<Chat>,
pub session: Option<UserId>, pub session: Option<UserId>,
@@ -117,6 +287,7 @@ pub enum UserIcon {
Normal, Normal,
Muted, Muted,
Deafened, Deafened,
Suppressed,
None, None,
} }
@@ -128,7 +299,7 @@ impl UserIcon {
use UserIcon::*; use UserIcon::*;
Some(match self { Some(match self {
Normal => asset!("assets/mic-svgrepo-com.svg"), Normal => asset!("assets/mic-svgrepo-com.svg"),
Muted => asset!("assets/mic-off-svgrepo-com.svg"), Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"),
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"), Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
None => return Option::None, None => return Option::None,
}) })
@@ -140,7 +311,7 @@ pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
let color = match icon { let color = match icon {
UserIcon::Normal => "var(--accent-normal)", UserIcon::Normal => "var(--accent-normal)",
UserIcon::Muted => "var(--accent-muted)", UserIcon::Muted => "var(--accent-muted)",
UserIcon::Deafened => "var(--accent-deafened)", UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)",
UserIcon::None => "var(--accent-normal)", UserIcon::None => "var(--accent-normal)",
}; };
@@ -176,26 +347,57 @@ pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle(); let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read(); let server = STATE.server.read();
let user = server.session.unwrap(); let user = server.session.unwrap();
let Some(state) = server.channels.get(&id) else { let Some(state) = server.channels_state.channels.get(&id) else {
return rsx!("missing channel {id}"); return rsx!("missing channel {id}");
}; };
let mut open = use_signal(|| true);
let has_children = !state.users.is_empty() || !state.children.is_empty();
rsx!( rsx!(
details { div {
class: "channel_details", class: "channel_details",
open: true,
summary { div {
class: "channel_header",
// Arrow: only toggles open
if has_children {
span { span {
role: "button", class: "channel_arrow",
ondoubleclick: move |evt| { onclick: move |evt| {
evt.stop_propagation();
evt.prevent_default();
let mut w = open.write();
*w = !*w;
},
if *open.read() { "" } else { "" }
}
} else {
span {
class: "channel_arrow channel_arrow--placeholder",
" "
}
}
// Clickable row area (everything except the arrow)
div {
class: "channel_row_click",
ondblclick: move |evt| {
evt.stop_propagation(); evt.stop_propagation();
evt.prevent_default(); evt.prevent_default();
net.send(EnterChannel { channel: id, user }) net.send(EnterChannel { channel: id, user })
}, },
// remove dblclick from the inner span
span {
class: "channel_title",
"{state.name}" "{state.name}"
} }
// if you add icons/badges later, put them here too
} }
if state.users.len() + state.children.len() > 0 { }
if *open.read() && has_children {
div { div {
class: "channel_children", class: "channel_children",
for id in state.users.iter() { for id in state.users.iter() {
@@ -210,6 +412,7 @@ pub fn Channel(id: ChannelId) -> Element {
) )
} }
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn pick_and_send_file(net: &Coroutine<Command>) { pub fn pick_and_send_file(net: &Coroutine<Command>) {
let channels = if let Some(user) = STATE.server.read().this_user() { let channels = if let Some(user) = STATE.server.read().this_user() {
vec![user.channel] vec![user.channel]
@@ -231,6 +434,8 @@ 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] #[component]
pub fn ChatView() -> Element { pub fn ChatView() -> Element {
@@ -285,17 +490,13 @@ pub fn ChatView() -> Element {
div { div {
span { span {
onclick: move |_| pick_and_send_file(&net), onclick: move |_| pick_and_send_file(&net),
class: "material-symbols-outlined", Icon { name: "attach_file", color: "#ffffff", opacity: 0.5 }
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 { div {
span { span {
onclick: move |_| do_send(), onclick: move |_| do_send(),
class: "material-symbols-outlined", Icon { name: "send", color: "#ffffff", opacity: 0.5 }
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",
} }
} }
} }
@@ -309,7 +510,7 @@ pub fn ChatView() -> Element {
} }
#[component] #[component]
pub fn ControlView(config: Resource<ClientConfig>) -> Element { pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle(); let net: Coroutine<Command> = use_coroutine_handle();
let status = &STATE.status; let status = &STATE.status;
let server = STATE.server.read(); let server = STATE.server.read();
@@ -317,6 +518,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
deaf, deaf,
self_deaf, self_deaf,
mute, mute,
suppress,
self_mute, self_mute,
ref name, ref name,
channel, channel,
@@ -326,12 +528,12 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
return rsx!(); return rsx!();
}; };
let current_channel_name = server.channels[&channel].name.clone(); let current_channel_name = server.channels_state.channels[&channel].name.clone();
let proxy_url = config let proxy_url = overrides
.read_unchecked() .read_unchecked()
.as_ref() .as_ref()
.and_then(|gui_config| gui_config.proxy_url.clone()); .and_then(|overrides| overrides.proxy_url.clone());
let connecting_color = "yellow"; let connecting_color = "yellow";
let connected_color = "oklch(0.55 0.1184 141.35)"; let connected_color = "oklch(0.55 0.1184 141.35)";
@@ -341,81 +543,59 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
let connection_status = match &*status.read() { let connection_status = match &*status.read() {
Connecting => rsx! { Connecting => rsx! {
div { div {
style: "color: \"{connecting_color}\";", class: "connection_status",
style: "color: {connecting_color};",
div {
Icon { name: "signal_cellular_alt_2_bar", color: "yellow" }
span { span {
class: "material-symbols-outlined", class: "status_text",
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" " Connecting"
} }
} }
}
}, },
Connected => rsx! { Connected => rsx! {
div { div {
class: "connection_status",
div { div {
style: "color: \"{connected_color}\";", style: "color: {connected_color};",
Icon { name: "signal_cellular_alt", color: "#46823e" }
span { span {
class: "material-symbols-outlined", class: "status_text",
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" " Connected"
} }
} }
div { div {
span { style: "width: 3px; display: inline-block;"} class: "channel_text",
span { "{current_channel_name}" } span { "{current_channel_name}" }
if let Some(proxy_url) = proxy_url {
span { "" }
span { "{proxy_url}" }
}
} }
} }
}, },
Disconnected => rsx! { Disconnected => rsx! {
div { div {
style: "color: \"{disconnected_color}\";", class: "connection_status",
style: "color: {disconnected_color};",
div {
Icon { name: "signal_disconnected", color: "gray" }
span { span {
class: "material-symbols-outlined", class: "status_text",
style: "vertical-align: middle;",
"signal_disconnected"
}
span {
style: "width: 5px; display: inline-block;"
}
span {
style: "vertical-align: middle;",
" Disconnected" " Disconnected"
} }
} }
}
}, },
Failed(_) => rsx! { Failed(_) => rsx! {
div { div {
style: "color: \"{failed_color}\";", class: "connection_status",
style: "color: {failed_color};",
div {
Icon { name: "error", color: "red" }
span { span {
class: "material-symbols-outlined", class: "status_text",
style: "vertical-align: middle;",
"error"
}
span {
style: "width: 5px; display: inline-block;"
}
span {
style: "vertical-align: middle;",
" Failed" " Failed"
} }
} }
}
}, },
}; };
@@ -431,10 +611,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
button { button {
class: "toggle_button", class: "toggle_button",
onclick: move |_| net.send(Disconnect), onclick: move |_| net.send(Disconnect),
span { Icon { name: "signal_disconnected", color: "#ffffff", opacity: 0.5 }
class: "material-symbols-outlined",
"signal_disconnected"
}
} }
} }
hr { style: "width: 100%;" } hr { style: "width: 100%;" }
@@ -443,18 +620,15 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
class: "button_row", class: "button_row",
button { button {
class: "user_edit_button", class: "user_edit_button",
span { Icon { name: "person_edit", color: "#fa3f36" }
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 { div {
class: "user_info",
div { div {
span { style: "font-size: 25px;", "{name}" } span { class: "user_name", "{name}" }
} }
div { div {
span { style: "font-size: 20px; color: gray;", "some data" } span { class: "user_data", "some data" }
} }
} }
span { class: "spacer" } span { class: "spacer" }
@@ -471,22 +645,22 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
net.send(UpdateMicEffects { denoise: new_denoise }) net.send(UpdateMicEffects { denoise: new_denoise })
}, },
match denoise() { match denoise() {
true => rsx!(span { class: "material-symbols-outlined", "cadence"}), true => rsx!(Icon { name: "cadence", color: "#b23f43", opacity: 0.8938 }),
false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}), false => rsx!(Icon { name: "graphic_eq", color: "#ffffff", opacity: 0.5 }),
} }
} }
button { button {
class: match mute || self_mute { class: match mute || suppress || self_mute {
true => "toggle_button is_on", true => "toggle_button is_on",
false => "toggle_button", false => "toggle_button",
}, },
role: "switch", role: "switch",
aria_checked: mute || self_mute, aria_checked: mute || suppress || self_mute,
disabled: mute, disabled: mute || suppress,
onclick: move |_| net.send(SetMute { mute: !self_mute }), onclick: move |_| net.send(SetMute { mute: !self_mute }),
match mute || self_mute { match mute || suppress || self_mute {
true => rsx!(span { class: "material-symbols-outlined", "mic_off"}), true => rsx!(Icon { name: "mic_off", color: "#b23f43", opacity: 0.8938 }),
false => rsx!(span { class: "material-symbols-outlined", "mic"}), false => rsx!(Icon { name: "mic", color: "#ffffff", opacity: 0.5 }),
} }
} }
button { button {
@@ -499,8 +673,8 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
disabled: deaf, disabled: deaf,
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }), onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
match deaf || self_deaf { match deaf || self_deaf {
true => rsx!(span { class: "material-symbols-outlined", "volume_off"}), true => rsx!(Icon { name: "volume_off", color: "#b23f43", opacity: 0.8938 }),
false => rsx!(span { class: "material-symbols-outlined", "volume_up"}), false => rsx!(Icon { name: "volume_up", color: "#ffffff", opacity: 0.5 }),
} }
} }
} }
@@ -508,7 +682,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
} }
#[component] #[component]
pub fn ServerView(config: Resource<ClientConfig>) -> Element { pub fn ServerView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem) -> Element {
let net: Coroutine<Command> = use_coroutine_handle(); let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read(); let server = STATE.server.read();
let Some(&UserState { let Some(&UserState {
@@ -527,7 +701,7 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
class: "server_grid", class: "server_grid",
div { div {
class: "server_channel_box", class: "server_channel_box",
for (id, state) in server.channels.iter() { for (id, state) in server.channels_state.channels.iter() {
if state.parent.is_none() { if state.parent.is_none() {
Channel { id: *id } Channel { id: *id }
} }
@@ -539,49 +713,48 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
} }
div { div {
class: "server_control_box", class: "server_control_box",
ControlView { config } ControlView { overrides }
} }
} }
) )
} }
#[component] #[component]
pub fn LoginView(config: Resource<ClientConfig>) -> Element { pub fn LoginView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem) -> Element {
let net: Coroutine<Command> = use_coroutine_handle(); let net: Coroutine<Command> = use_coroutine_handle();
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>); let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
use_resource(move || async move { use_resource(move || async move {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
loop { loop {
*last_status.write_unchecked() = Some(imp::get_status(&client).await); *last_status.write_unchecked() = Some(Platform::get_status(&client).await);
imp::sleep(std::time::Duration::from_secs_f32(1.0)).await; Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await;
} }
}); });
let mut address_input = use_signal(|| imp::load_server_url()); let mut address_input = use_signal(|| user_config.config_get::<String>("server_url"));
let address = use_memo(move || { let address = use_memo(move || {
if let Some(addr) = address_input() { if let Some(addr) = address_input() {
addr.clone() addr.clone()
} else { } else {
config() overrides()
.and_then(|c| c.proxy_url.clone()) .and_then(|c| c.proxy_url.clone())
.unwrap_or_default() .unwrap_or_default()
} }
}); });
let previous_username = imp::load_username(); let previous_username = user_config.config_get::<String>("username");
let mut username = use_signal(|| previous_username.unwrap_or(String::new())); let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
let do_connect = move |_| { let do_connect = move |_| {
//let _ = set_default_username(&username.read()); let _ = user_config.config_set::<String>("username", &username.read());
let _ = imp::set_default_username(&username.read()); if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) {
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) { user_config.config_set::<String>("server_url", &address.read());
imp::set_default_server(&address.read());
} }
net.send(Connect { net.send(Connect {
address: address.read().clone(), address: address.read().clone(),
username: username.read().clone(), username: username.read().clone(),
config: config.read().clone().unwrap_or_default(), config: overrides.read().clone().unwrap_or_default(),
}) })
}; };
let status = &STATE.status; let status = &STATE.status;
@@ -626,7 +799,7 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
None => rsx!(), None => rsx!(),
} }
} }
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) { if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) {
div { div {
label { label {
for: "address-entry", for: "address-entry",
@@ -722,21 +895,24 @@ pub fn app() -> Element {
static STYLE: Asset = asset!("/assets/main.scss"); static STYLE: Asset = asset!("/assets/main.scss");
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx)); use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
let config = use_resource(|| async move { let overrides = use_resource(|| async move {
match imp::load_config().await { match Platform::load_proxy_overrides().await {
Ok(config) => config, Ok(overrides) => overrides,
Err(_) => ClientConfig::default(), Err(_) => ProxyOverrides::default(),
} }
}); });
let user_config = ConfigSystem::new().unwrap();
Platform::request_permissions();
rsx!( 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=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 } document::Link{ rel: "stylesheet", href: STYLE }
match *STATE.status.read() { match *STATE.status.read() {
Connected => rsx!(ServerView { config }), Connected => rsx!(ServerView { overrides, user_config }),
_ => rsx!(LoginView { config }), _ => rsx!(LoginView { overrides, user_config }),
} }
) )
} }
+72 -9
View File
@@ -1,15 +1,33 @@
use crossbeam::atomic::AtomicCell; use crossbeam::atomic::AtomicCell;
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview}; use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
use df::tract::{DfParams, DfTract, RuntimeParams}; 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::prelude::{asset, manganis, Asset};
use dioxus_asset_resolver::read_asset_bytes; use dioxus_asset_resolver::read_asset_bytes;
use std::cell::RefCell; use std::cell::RefCell;
use std::sync::Arc; use std::sync::Arc;
use tracing::{error, info}; use tracing::{error, info};
use crate::imp; use crate::imp::SpawnHandle;
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz"); 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 { enum DenoisingModelState {
Nothing, Nothing,
@@ -17,10 +35,7 @@ enum DenoisingModelState {
Availible(Box<DfTract>), Availible(Box<DfTract>),
} }
fn with_denoising_model<O>( fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract) -> O) -> Option<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 // 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) while AudioProcessing itself might change threads whenever.
thread_local! { thread_local! {
@@ -74,30 +89,46 @@ fn with_denoising_model<O>(
pub struct AudioProcessor { pub struct AudioProcessor {
denoise: bool, denoise: bool,
spawn: imp::SpawnHandle, spawn: SpawnHandle,
buffer: Vec<f32>, 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 { impl AudioProcessor {
pub fn new_plain() -> Self { pub fn new_plain() -> Self {
AudioProcessor { AudioProcessor {
denoise: false, denoise: false,
spawn: imp::SpawnHandle::current(), spawn: SpawnHandle::current(),
buffer: Vec::new(), buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
hold_samples: 0,
} }
} }
pub fn new_denoising() -> Self { pub fn new_denoising() -> Self {
AudioProcessor { AudioProcessor {
denoise: true, denoise: true,
spawn: imp::SpawnHandle::current(), spawn: SpawnHandle::current(),
buffer: Vec::new(), buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
hold_samples: 0,
} }
} }
} }
impl AudioProcessor { impl AudioProcessor {
pub fn process(&mut self, audio: &[f32], channels: usize, output: &mut Vec<f32>) { pub fn process(
&mut self,
audio: &[f32],
channels: usize,
output: &mut Vec<f32>,
) -> TransmitState {
let mut include_raw = true; let mut include_raw = true;
if self.denoise { if self.denoise {
with_denoising_model(&self.spawn, |df| { with_denoising_model(&self.spawn, |df| {
@@ -132,6 +163,38 @@ impl AudioProcessor {
if include_raw { if include_raw {
output.extend(audio.iter().step_by(channels).copied()); output.extend(audio.iter().step_by(channels).copied());
} }
// 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
@@ -0,0 +1,91 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::future::Future;
use std::time::Duration;
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
pub struct MobilePlatform;
impl super::PlatformInterface for MobilePlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
fn load_username() -> Option<String> {
None
}
fn load_server_url() -> Option<String> {
None
}
fn set_default_username(_username: &str) -> Option<()> {
None
}
fn set_default_server(server: &str) -> Option<()> {
None
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, gui_config).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
super::connect::get_status(client).await
}
fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
fn request_permissions() {
request_recording_permission();
}
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
}
#[cfg(not(target_os = "android"))]
pub fn request_recording_permission() {}
#[cfg(target_os = "android")]
pub fn request_recording_permission() {
use android_permissions::{PermissionManager, RECORD_AUDIO};
use jni::{objects::JObject, JavaVM};
let ctx = ndk_context::android_context();
let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() };
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
let manager = PermissionManager::create(vm, activity).unwrap();
if !manager.check(&RECORD_AUDIO).unwrap() {
manager.request(&[&RECORD_AUDIO]).unwrap();
}
}
+115
View File
@@ -0,0 +1,115 @@
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;
+29 -364
View File
@@ -1,384 +1,44 @@
use crate::app::Command; use crate::app::Command;
use crate::effects::{AudioProcessor, AudioProcessorSender}; use color_eyre::eyre::Error;
use color_eyre::eyre::{bail, eyre, Error};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
use dioxus::hooks::UnboundedReceiver; use dioxus::hooks::UnboundedReceiver;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use futures::io::{AsyncRead, AsyncWrite}; use mumble_web2_common::{ProxyOverrides, ServerStatus};
use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::collections::HashMap; use std::collections::HashMap;
use std::mem::replace; use std::time::Duration;
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};
pub use tokio::runtime::Handle as SpawnHandle; /// Desktop platform implementation using Tokio and native audio.
pub use tokio::task::spawn; pub struct DesktopPlatform;
pub use tokio::time::sleep;
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {} impl super::PlatformInterface for DesktopPlatform {
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {} type AudioSystem = super::native_audio::NativeAudioSystem;
type ConfigSystem = super::native_config::NativeConfigSystem;
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {} async fn sleep(duration: Duration) {
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {} tokio::time::sleep(duration).await;
pub struct AudioSystem {
output: cpal::Device,
input: cpal::Device,
processors: AudioProcessorSender,
recording_stream: Option<cpal::Stream>,
} }
const SAMPLE_RATE: u32 = 48_000; async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
const PACKET_SAMPLES: u32 = 960; Ok(ProxyOverrides {
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
impl AudioSystem {
pub async fn new() -> Result<Self, Error> {
// TODO
let host = cpal::default_host();
let name = host.id();
let processors = AudioProcessorSender::default();
Ok(AudioSystem {
output: host
.default_output_device()
.ok_or(eyre!("no output devices from {name:?}"))?,
input: host
.default_input_device()
.ok_or(eyre!("no input devices from {name:?}"))?,
processors,
recording_stream: None,
})
}
pub fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
fn choose_config(
&self,
configs: impl Iterator<Item = cpal::SupportedStreamConfigRange>,
) -> 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"))
}
pub fn start_recording(
&mut self,
mut each: impl FnMut(Vec<u8>) + 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;
}
current_processor.process(frame, config.channels as usize, &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(&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())
}
}
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let config = self.choose_config(self.input.supported_input_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(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
}
fn get_config_path() -> std::path::PathBuf {
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "com".to_string(),
author: "Ohea Corp".to_string(),
app_name: "Mumble Web2".to_string(),
})
.expect("failed to choose app strategy");
strategy.config_dir().join("config.json")
}
fn load_config_map() -> HashMap<String, String> {
let config_path = get_config_path();
match std::fs::read_to_string(&config_path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> {
let config_path = get_config_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(config)?;
std::fs::write(&config_path, contents)?;
Ok(())
}
pub fn set_default_username(username: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("username".to_string(), username.to_string());
save_config_map(&config).ok()
}
pub fn set_default_server(server: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("server".to_string(), server.to_string());
save_config_map(&config).ok()
}
pub fn load_username() -> Option<String> {
let config = load_config_map();
config.get("username").cloned()
}
pub fn load_server_url() -> Option<String> {
let config = load_config_map();
config.get("server").cloned()
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None, proxy_url: None,
cert_hash: None, cert_hash: None,
any_server: true, any_server: true,
}) })
} }
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> { async fn network_connect(
bail!("status not supported on desktop yet") address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
overrides: &ProxyOverrides,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, overrides).await
} }
pub fn init_logging() { 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::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter; use tracing_subscriber::filter::EnvFilter;
@@ -392,3 +52,8 @@ pub fn init_logging() {
.with_env_filter(env_filter) .with_env_filter(env_filter)
.init(); .init();
} }
fn request_permissions() {
// No-op on desktop
}
}
+75
View File
@@ -0,0 +1,75 @@
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();
}
}
+179 -5
View File
@@ -1,11 +1,185 @@
#[cfg(feature = "web")] //! Platform abstraction layer
mod web; //!
//! 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 = "desktop")] #[cfg(feature = "desktop")]
mod desktop; mod desktop;
#[cfg(all(feature = "web", not(feature = "desktop")))] #[cfg(feature = "mobile")]
pub use web::*; mod mobile;
#[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")] #[cfg(feature = "desktop")]
pub use 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()
}
+219
View File
@@ -0,0 +1,219 @@
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
@@ -0,0 +1,121 @@
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
@@ -0,0 +1,126 @@
/// 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")
}
+187 -108
View File
@@ -1,14 +1,16 @@
use crate::app::Command; use crate::app::Command;
use crate::effects::{AudioProcessor, AudioProcessorSender}; use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{bail, eyre, Error}; use color_eyre::eyre::{bail, eyre, Error};
use crossbeam::atomic::AtomicCell;
use dioxus::prelude::*; use dioxus::prelude::*;
use futures::{AsyncRead, AsyncWrite};
use gloo_timers::future::TimeoutFuture; use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array; use js_sys::Float32Array;
use mumble_protocol::control::ClientControlCodec; use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ClientConfig, ServerStatus}; use mumble_web2_common::{ProxyOverrides, ServerStatus};
use reqwest::Url; use reqwest::Url;
use std::collections::HashMap;
use std::future::Future; use std::future::Future;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing::{debug, error, info, instrument}; use tracing::{debug, error, info, instrument};
@@ -27,7 +29,6 @@ use web_sys::AudioWorkletNode;
use web_sys::EncodedAudioChunk; use web_sys::EncodedAudioChunk;
use web_sys::EncodedAudioChunkInit; use web_sys::EncodedAudioChunkInit;
use web_sys::EncodedAudioChunkType; use web_sys::EncodedAudioChunkType;
use web_sys::MediaStream;
use web_sys::MediaStreamConstraints; use web_sys::MediaStreamConstraints;
use web_sys::MessageEvent; use web_sys::MessageEvent;
use web_sys::WebTransport; use web_sys::WebTransport;
@@ -37,16 +38,95 @@ use web_sys::WorkletOptions;
use web_sys::{console, window}; use web_sys::{console, window};
use web_sys::{AudioContext, AudioDataCopyToOptions}; use web_sys::{AudioContext, AudioDataCopyToOptions};
#[allow(unused)]
pub use wasm_bindgen_futures::spawn_local as spawn; pub use wasm_bindgen_futures::spawn_local as spawn;
pub trait ImpRead: AsyncRead + Unpin + 'static {} #[allow(unused)]
impl<T: AsyncRead + Unpin + 'static> ImpRead for T {} #[derive(Clone)]
pub struct SpawnHandle;
pub trait ImpWrite: AsyncWrite + Unpin + 'static {} impl SpawnHandle {
impl<T: AsyncWrite + Unpin + 'static> ImpWrite for T {} pub fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + 'static,
{
wasm_bindgen_futures::spawn_local(future);
}
pub async fn sleep(d: Duration) { pub fn current() -> Self {
TimeoutFuture::new(d.as_millis() as u32).await 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;
}
} }
trait ResultExt<T> { trait ResultExt<T> {
@@ -71,7 +151,7 @@ impl<T> ResultExt<T> for Result<T, JsError> {
} }
} }
pub struct AudioSystem { pub struct WebAudioSystem {
webctx: AudioContext, webctx: AudioContext,
processors: AudioProcessorSender, processors: AudioProcessorSender,
} }
@@ -102,8 +182,10 @@ async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
Ok(()) Ok(())
} }
impl AudioSystem { impl super::AudioSystemInterface for WebAudioSystem {
pub async fn new() -> Result<Self, Error> { type AudioPlayer = WebAudioPlayer;
async fn new() -> Result<Self, Error> {
// Create MediaStreams to playback decoded audio // Create MediaStreams to playback decoded audio
// The audio context is used to reproduce audio. // The audio context is used to reproduce audio.
let webctx = configure_audio_context(); let webctx = configure_audio_context();
@@ -111,14 +193,14 @@ impl AudioSystem {
let processors = AudioProcessorSender::default(); let processors = AudioProcessorSender::default();
Ok(AudioSystem { webctx, processors }) Ok(WebAudioSystem { webctx, processors })
} }
pub fn set_processor(&self, processor: AudioProcessor) { fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor)) self.processors.store(Some(processor))
} }
pub fn start_recording(&mut self, each: impl FnMut(Vec<u8>) + 'static) -> Result<(), Error> { fn start_recording(&mut self, each: impl FnMut(Vec<u8>, bool) + 'static) -> Result<(), Error> {
let audio_context_worklet = self.webctx.clone(); let audio_context_worklet = self.webctx.clone();
let processors = self.processors.clone(); let processors = self.processors.clone();
spawn(async move { spawn(async move {
@@ -130,7 +212,7 @@ impl AudioSystem {
Ok(()) Ok(())
} }
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> { fn create_player(&mut self) -> Result<WebAudioPlayer, Error> {
let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?; let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?;
// Connect worklet to destination // Connect worklet to destination
@@ -183,14 +265,14 @@ impl AudioSystem {
decoder_error.forget(); decoder_error.forget();
output.forget(); output.forget();
Ok(AudioPlayer(audio_decoder)) Ok(WebAudioPlayer(audio_decoder))
} }
} }
pub struct AudioPlayer(AudioDecoder); pub struct WebAudioPlayer(AudioDecoder);
impl AudioPlayer { impl super::AudioPlayerInterface for WebAudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) { fn play_opus(&mut self, payload: &[u8]) {
let js_audio_payload = Uint8Array::from(payload); let js_audio_payload = Uint8Array::from(payload);
let _ = self.0.decode( let _ = self.0.decode(
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new( &EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
@@ -222,22 +304,26 @@ impl PromiseExt for Promise {
} }
} }
fn process_audio(frame: &JsValue, processor: &mut AudioProcessor) { fn process_audio(frame: &JsValue, processor: &mut AudioProcessor) -> TransmitState {
let Ok(samples) = Reflect::get(&frame, &"data".into()) else { let Ok(samples) = Reflect::get(&frame, &"data".into()) else {
return; return TransmitState::Silent;
}; };
let Ok(samples) = samples.dyn_into::<Float32Array>() else { let Ok(samples) = samples.dyn_into::<Float32Array>() else {
return; return TransmitState::Silent;
}; };
let input = samples.to_vec(); let input = samples.to_vec();
let mut output = Vec::with_capacity(input.len()); let mut output = Vec::with_capacity(input.len());
processor.process(&input, 1, &mut output); let state = processor.process(&input, 1, &mut output);
if !output.is_empty() {
samples.copy_from(&output); samples.copy_from(&output);
} }
state
}
async fn run_encoder_worklet( async fn run_encoder_worklet(
audio_context: &AudioContext, audio_context: &AudioContext,
mut each: impl FnMut(Vec<u8>) + 'static, mut each: impl FnMut(Vec<u8>, bool) + 'static,
processors: AudioProcessorSender, processors: AudioProcessorSender,
) -> Result<AudioWorkletNode, Error> { ) -> Result<AudioWorkletNode, Error> {
let constraints = MediaStreamConstraints::new(); let constraints = MediaStreamConstraints::new();
@@ -262,12 +348,19 @@ async fn run_encoder_worklet(
let encoder_error: Closure<dyn FnMut(JsValue)> = let encoder_error: Closure<dyn FnMut(JsValue)> =
Closure::new(|e| error!("error encoding audio {:?}", e)); 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 // This knows what MediaStreamTrackGenerator to use as it closes around it
let output: Closure<dyn FnMut(EncodedAudioChunk)> = let output: Closure<dyn FnMut(EncodedAudioChunk)> =
Closure::new(move |audio_data: EncodedAudioChunk| { Closure::new(move |audio_data: EncodedAudioChunk| {
let mut array = vec![0u8; audio_data.byte_length() as usize]; let mut array = vec![0u8; audio_data.byte_length() as usize];
audio_data.copy_to_with_u8_slice(&mut array); audio_data.copy_to_with_u8_slice(&mut array);
each(array); // Check if this frame was marked as a terminator
let is_terminator = pending_terminator_output.swap(false);
each(array, is_terminator);
}); });
let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new( let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new(
@@ -294,8 +387,19 @@ async fn run_encoder_worklet(
} }
let frame = event.data(); let frame = event.data();
process_audio(&frame, &mut current_processor); let state = 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()) { match AudioData::new(frame.unchecked_ref()) {
Ok(data) => { Ok(data) => {
let _ = audio_encoder.encode(&data); let _ = audio_encoder.encode(&data);
@@ -329,7 +433,7 @@ pub async fn network_connect(
address: String, address: String,
username: String, username: String,
event_rx: &mut UnboundedReceiver<Command>, event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig, overrides: &ProxyOverrides,
) -> Result<(), Error> { ) -> Result<(), Error> {
info!("connecting"); info!("connecting");
@@ -342,7 +446,7 @@ pub async fn network_connect(
) )
.ey()?; .ey()?;
if let Some(server_hash) = &gui_config.cert_hash { if let Some(server_hash) = &overrides.cert_hash {
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice()); let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?; web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?;
} }
@@ -391,94 +495,69 @@ pub async fn network_connect(
crate::network_loop(username, event_rx, reader, writer).await crate::network_loop(username, event_rx, reader, writer).await
} }
pub fn set_default_username(username: &str) -> Option<()> {
web_sys::window()?
.local_storage()
.ok()??
.set_item("username", username)
.ok()
}
pub fn set_default_server(username: &str) -> Option<()> {
None
}
pub fn load_username() -> Option<String> {
web_sys::window()
.unwrap()
.local_storage()
.ok()??
.get_item("username")
.ok()?
}
pub fn load_server_url() -> Option<String> {
None
}
pub fn absolute_url(path: &str) -> Result<Url, Error> { pub fn absolute_url(path: &str) -> Result<Url, Error> {
let window: web_sys::Window = web_sys::window().expect("no global `window` exists"); let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
let location = window.location(); let location = window.location();
Ok(Url::parse(&location.href().ey()?)?.join(path)?) Ok(Url::parse(&location.href().ey()?)?.join(path)?)
} }
pub async fn load_config() -> color_eyre::Result<ClientConfig> { #[derive(Clone, PartialEq)]
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") { pub struct WebConfigSystem {}
Some(url) => Url::parse(url)?,
None => absolute_url("config")?,
};
info!("loading config from {}", config_url);
let config = reqwest::get(config_url) impl super::ConfigSystemInterface for WebConfigSystem {
.await? fn new() -> Result<Self, Error> {
.json::<ClientConfig>() return Ok(WebConfigSystem {});
.await?;
Ok(config)
} }
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> { fn config_get<T>(&self, key: &str) -> Option<T>
Ok(client
.get(absolute_url("status")?)
.send()
.await?
.json::<ServerStatus>()
.await?)
}
pub fn init_logging() {
// copied from tracing_web example usage
use tracing_subscriber::fmt::format::Pretty;
use tracing_subscriber::prelude::*;
use tracing_web::{performance_layer, MakeWebConsoleWriter};
let fmt_layer = tracing_subscriber::fmt::layer()
.with_ansi(false) // Only partially supported across browsers
.without_time() // std::time is not available in browsers
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
.with_filter(LevelFilter::DEBUG);
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
tracing_subscriber::registry()
.with(fmt_layer)
.with(perf_layer)
.init();
info!("logging initiated");
}
pub struct SpawnHandle;
impl SpawnHandle {
pub fn current() -> Self {
SpawnHandle
}
pub fn spawn<F>(&self, future: F)
where where
F: Future<Output = ()> + 'static, T: serde::de::DeserializeOwned,
{ {
spawn(future); // Get Storage
let storage = web_sys::window()?.local_storage().ok()??;
// Try localStorage first
if let Ok(Some(raw)) = storage.get_item(key) {
if let Ok(parsed) = serde_json::from_str::<T>(&raw) {
return Some(parsed);
} }
} }
// Fallback to default if deserialization fails or key missing
let default_value = config_get_default(key)?;
serde_json::from_value::<T>(default_value).ok()
}
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize,
{
let storage = window()
.and_then(|w| w.local_storage().ok().flatten())
.expect("localStorage not available");
let json_value =
serde_json::to_string(value).expect("failed to serialize config value to JSON string");
storage
.set_item(key, &json_value)
.expect("failed to write to localStorage");
}
}
fn config_get_default(key: &str) -> Option<serde_json::Value> {
let default_config = platform_default_config();
default_config
.get(key)
.cloned()
.or(super::global_default_config().get(key).cloned())
}
fn platform_default_config() -> HashMap<String, serde_json::Value> {
serde_json::json!({})
.as_object()
.unwrap()
.clone()
.into_iter()
.collect()
}
+27 -47
View File
@@ -5,13 +5,17 @@ use app::STATE;
use asynchronous_codec::FramedRead; use asynchronous_codec::FramedRead;
use asynchronous_codec::FramedWrite; use asynchronous_codec::FramedWrite;
use color_eyre::eyre::{bail, Error}; use color_eyre::eyre::{bail, Error};
#[cfg(feature = "blitz")]
use dioxus_native::prelude::*;
#[cfg(not(feature = "blitz"))]
use dioxus::prelude::*; use dioxus::prelude::*;
use futures::select; use futures::select;
use futures::AsyncRead;
use futures::AsyncWrite;
use futures::FutureExt as _; use futures::FutureExt as _;
use futures::SinkExt as _; use futures::SinkExt as _;
use futures::StreamExt as _; use futures::StreamExt as _;
use futures_channel::mpsc::UnboundedSender; use futures_channel::mpsc::UnboundedSender;
pub use imp::spawn;
use msghtml::process_message_html; use msghtml::process_message_html;
use mumble_protocol::control::msgs; use mumble_protocol::control::msgs;
use mumble_protocol::control::ControlCodec; use mumble_protocol::control::ControlCodec;
@@ -20,17 +24,17 @@ use mumble_protocol::voice::VoicePacket;
use mumble_protocol::voice::VoicePacketPayload; use mumble_protocol::voice::VoicePacketPayload;
use mumble_protocol::Clientbound; use mumble_protocol::Clientbound;
use mumble_protocol::Serverbound; use mumble_protocol::Serverbound;
use mumble_web2_common::ClientConfig;
use once_cell::sync::Lazy;
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
use tracing::debug;
use tracing::error; use tracing::error;
use tracing::info; use tracing::info;
use crate::effects::AudioProcessor; use crate::effects::AudioProcessor;
use crate::imp::AudioSystem; use crate::imp::{
AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
PlatformInterface as _,
};
pub mod app; pub mod app;
mod effects; mod effects;
@@ -50,7 +54,9 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
*STATE.server.write() = Default::default(); *STATE.server.write() = Default::default();
*STATE.status.write() = ConnectionState::Connecting; *STATE.status.write() = ConnectionState::Connecting;
if let Err(error) = imp::network_connect(address, username, &mut event_rx, &config).await { if let Err(error) =
Platform::network_connect(address, username, &mut event_rx, &config).await
{
error!("could not connect {:?}", error); error!("could not connect {:?}", error);
*STATE.status.write() = ConnectionState::Failed(error.to_string()); *STATE.status.write() = ConnectionState::Failed(error.to_string());
} else { } else {
@@ -59,7 +65,7 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
} }
} }
pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>( pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin + 'static>(
username: String, username: String,
event_rx: &mut UnboundedReceiver<Command>, event_rx: &mut UnboundedReceiver<Command>,
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>, mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
@@ -108,23 +114,23 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
break; break;
} }
imp::sleep(Duration::from_millis(3000)).await; Platform::sleep(Duration::from_millis(3000)).await;
} }
}); });
} }
let mut audio = imp::AudioSystem::new().await?; let mut audio = AudioSystem::new().await?;
{ {
let send_chan = send_chan.clone(); let send_chan = send_chan.clone();
let mut sequence_num = 0; let mut sequence_num = 0;
audio.start_recording(move |opus_frame| { audio.start_recording(move |opus_frame, is_terminator| {
let _ = let _ =
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio { send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
_dst: std::marker::PhantomData, _dst: std::marker::PhantomData,
target: 0, target: 0,
session_id: (), session_id: (),
seq_num: sequence_num, seq_num: sequence_num,
payload: VoicePacketPayload::Opus(opus_frame.into(), false), payload: VoicePacketPayload::Opus(opus_frame.into(), is_terminator),
position_info: None, position_info: None,
}))); })));
sequence_num = sequence_num.wrapping_add(2); sequence_num = sequence_num.wrapping_add(2);
@@ -299,8 +305,8 @@ fn accept_command(
fn accept_packet( fn accept_packet(
msg: ControlPacket<mumble_protocol::Clientbound>, msg: ControlPacket<mumble_protocol::Clientbound>,
audio_context: &mut imp::AudioSystem, audio_context: &mut AudioSystem,
player_map: &mut HashMap<u32, imp::AudioPlayer>, player_map: &mut HashMap<u32, AudioPlayer>,
) -> Result<(), Error> { ) -> Result<(), Error> {
match msg { match msg {
ControlPacket::UDPTunnel(u) => { ControlPacket::UDPTunnel(u) => {
@@ -338,41 +344,11 @@ fn accept_packet(
} }
ControlPacket::ChannelState(u) => { ControlPacket::ChannelState(u) => {
let mut server = STATE.server.write(); let mut server = STATE.server.write();
let id = u.get_channel_id(); server.channels_state.update_from_channel_state(&u);
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) => { ControlPacket::ChannelRemove(u) => {
let mut server = STATE.server.write(); let mut server = STATE.server.write();
let id = u.get_channel_id(); server.channels_state.update_from_channel_remove(&u);
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) => { ControlPacket::UserState(u) => {
let mut server = STATE.server.write(); let mut server = STATE.server.write();
@@ -384,12 +360,13 @@ fn accept_packet(
let state = state_entry.or_default(); let state = state_entry.or_default();
// the server might now send a channel_id if the user is in channel=0 // the server might now send a channel_id if the user is in channel=0
if u.has_channel_id() || new { if u.has_channel_id() || new {
if let Some(parent) = server.channels.get_mut(&state.channel) { if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
parent.users.remove(&id); parent.users.remove(&id);
} }
let channel_id = u.get_channel_id(); let channel_id = u.get_channel_id();
server server
.channels_state
.channels .channels
.entry(channel_id) .entry(channel_id)
.or_default() .or_default()
@@ -407,6 +384,9 @@ fn accept_packet(
if u.has_deaf() { if u.has_deaf() {
state.deaf = u.get_deaf(); state.deaf = u.get_deaf();
} }
if u.has_suppress() {
state.suppress = u.get_suppress();
}
if u.has_self_mute() { if u.has_self_mute() {
state.self_mute = u.get_self_mute(); state.self_mute = u.get_self_mute();
} }
@@ -418,7 +398,7 @@ fn accept_packet(
let mut server = STATE.server.write(); let mut server = STATE.server.write();
let id = u.get_session(); let id = u.get_session();
if let Some(state) = server.users.remove(&id) { if let Some(state) = server.users.remove(&id) {
if let Some(parent) = server.channels.get_mut(&state.channel) { if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
parent.users.remove(&id); parent.users.remove(&id);
} }
} }
+5 -2
View File
@@ -1,6 +1,9 @@
use mumble_web2_gui::{app, imp}; use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
pub fn main() { pub fn main() {
imp::init_logging(); Platform::init_logging();
#[cfg(feature = "blitz")]
dioxus_native::launch(app::app);
#[cfg(not(feature = "blitz"))]
dioxus::launch(app::app); dioxus::launch(app::app);
} }
+17 -26
View File
@@ -1,5 +1,5 @@
use color_eyre::eyre::{anyhow, bail, Context, Result}; use color_eyre::eyre::{anyhow, bail, Context, Result};
use mumble_web2_common::{ClientConfig, ServerStatus}; use mumble_web2_common::{ProxyOverrides, ServerStatus};
use rand::Rng; use rand::Rng;
use salvo::conn::rustls::{Keycert, RustlsConfig}; use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::cors::{AllowOrigin, Cors}; use salvo::cors::{AllowOrigin, Cors};
@@ -16,7 +16,7 @@ use tokio::net::TcpStream;
use tokio::pin; use tokio::pin;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier}; use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::{ClientConfig as RlsClientConfig, DigitallySignedStruct}; use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct};
use tokio_rustls::{rustls, TlsConnector}; use tokio_rustls::{rustls, TlsConnector};
use tracing::info; use tracing::info;
use tracing::info_span; use tracing::info_span;
@@ -77,7 +77,7 @@ async fn main() -> Result<()> {
.install_default() .install_default()
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?; .map_err(|e| anyhow!("could not install crypto provider {e:?}"))?;
let mut client_config = ClientConfig { let mut overrides = ProxyOverrides {
proxy_url: match &server_config.proxy_url { proxy_url: match &server_config.proxy_url {
Some(url) => Some(url.to_string()), Some(url) => Some(url.to_string()),
None => None, None => None,
@@ -102,7 +102,7 @@ async fn main() -> Result<()> {
let cert = cert_params.self_signed(&key_pair)?; let cert = cert_params.self_signed(&key_pair)?;
let hash = hmac_sha256::Hash::hash(cert.der().as_ref()); let hash = hmac_sha256::Hash::hash(cert.der().as_ref());
client_config.cert_hash = Some(hash.into()); overrides.cert_hash = Some(hash.into());
(cert.pem().into(), key_pair.serialize_pem().into()) (cert.pem().into(), key_pair.serialize_pem().into())
} }
@@ -122,14 +122,11 @@ async fn main() -> Result<()> {
}; };
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice())); let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
info!( info!("proxy overrides:\n{}", toml::to_string_pretty(&overrides)?);
"client config:\n{}",
toml::to_string_pretty(&client_config)?
);
let config_craft = ConfigCraft { let config_craft = ConfigCraft {
server_config: server_config.clone(), server_config: server_config.clone(),
client_config, overrides,
}; };
let status_craft = StatusCraft { let status_craft = StatusCraft {
@@ -139,7 +136,7 @@ async fn main() -> Result<()> {
// Server routing // Server routing
let mut router = Router::new() let mut router = Router::new()
.push(Router::with_path("/proxy").goal(config_craft.connect_proxy())) .push(Router::with_path("/proxy").goal(config_craft.connect_proxy()))
.push(Router::with_path("/config").get(config_craft.get_config())) .push(Router::with_path("/overrides").get(config_craft.get_overrides()))
.push(Router::with_path("/status").get(status_craft.get_status())) .push(Router::with_path("/status").get(status_craft.get_status()))
.hoop(Logger::new()); .hoop(Logger::new());
if let Some(gui_path) = server_config.gui_path.clone() { if let Some(gui_path) = server_config.gui_path.clone() {
@@ -252,14 +249,14 @@ impl StatusCraft {
#[derive(Clone)] #[derive(Clone)]
pub struct ConfigCraft { pub struct ConfigCraft {
server_config: Arc<Config>, server_config: Arc<Config>,
client_config: ClientConfig, overrides: ProxyOverrides,
} }
#[craft] #[craft]
impl ConfigCraft { impl ConfigCraft {
#[craft(handler)] #[craft(handler)]
async fn get_config(&self) -> Json<ClientConfig> { async fn get_overrides(&self) -> Json<ProxyOverrides> {
Json(self.client_config.clone()) Json(self.overrides.clone())
} }
#[craft(handler)] #[craft(handler)]
@@ -320,7 +317,7 @@ async fn connect_proxy_impl(
) -> Result<()> { ) -> Result<()> {
info!("connecting to Mumble server..."); info!("connecting to Mumble server...");
let config = RlsClientConfig::builder() let config = ClientConfig::builder()
.dangerous() .dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification)) .with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth(); .with_no_client_auth();
@@ -335,19 +332,13 @@ async fn connect_proxy_impl(
info!("connected to Mumble server"); info!("connected to Mumble server");
// Spawn tasks to handle transmitting data between the WebTransport client and Mumble TCP Server // Handle transmitting data between the WebTransport client and Mumble TCP Server
let c2s = tokio::spawn( // When one direction completes/fails, the other is dropped and its streams are closed
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! { tokio::select! {
res = c2s => res??, res = pass_bytes_loop(incoming, write_server)
res = s2c => res??, .instrument(info_span!("Handler", "Client to server")) => res?,
res = pass_bytes_loop(read_server, outgoing)
.instrument(info_span!("Handler", "Server to client")) => res?,
}; };
Ok(()) Ok(())
} }