77 Commits

Author SHA1 Message Date
restitux dab00dce2f disable rust cache in linux ci
Build Mumble Web 2 / windows_build (push) Successful in 2m46s
Build Mumble Web 2 / linux_build (push) Successful in 4m20s
Build Mumble Web 2 / android_build (push) Successful in 5m56s
2026-01-24 22:00:11 -07:00
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
samuel127849 4abb130a77 Address @sam
Build Mumble Web 2 / linux_build (push) Successful in 1m33s
Build Mumble Web 2 / windows_build (push) Successful in 2m23s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
2026-01-10 23:26:21 +00:00
samuel127849 af35d72e4e Added persistant settings on desktop 2026-01-10 23:26:21 +00:00
samuel127849 889bdf6b80 Add configuration for rust analyzer for vscode. 2026-01-10 23:26:21 +00:00
liamwarfield 391d18a11e web: fix flash of unstyled content during load
Build Mumble Web 2 / linux_build (push) Successful in 1m28s
Build Mumble Web 2 / windows_build (push) Has been cancelled
Add background color to #main in loader styles to prevent white flash
while app CSS loads after WASM initialization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:24:15 -07:00
liamwarfield ca8a3d1b92 web: add loading screen while WASM fetches
Shows a themed spinner overlay while the large WASM bundle downloads,
improving perceived load time on slower connections.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 14:09:37 -07:00
restitux 5d2c2a93c7 ci: upgrade version of dioxus cli installed during linux build to 0.7.2 (#8)
Build Mumble Web 2 / linux_build (push) Successful in 1m42s
Build Mumble Web 2 / windows_build (push) Successful in 2m30s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 12s
Forgot to update this with the rest of the 0.7.2 migration

Reviewed-on: #8
2026-01-05 09:40:40 +00:00
restitux 96589e28c6 meta: update Dioxus to 0.7.2 (#6)
Build Mumble Web 2 / linux_build (push) Successful in 1m50s
Build Mumble Web 2 / windows_build (push) Successful in 4m16s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
Build with latest container: https://git.ohea.xyz/mumble/mumble-web2/actions/runs/192/jobs/1

Reviewed-on: #6
2026-01-05 01:45:40 +00:00
restitux e7e7b945c5 gui: switch to upstream version of rfd (#7)
Build Mumble Web 2 / windows_build (push) Has been cancelled
Build Mumble Web 2 / linux_build (push) Has been cancelled
The PR was merged into upstream. They haven't cut a release yet though so we still need to pull from git.

Reviewed-on: #7
Reviewed-by: Sam Sartor <cap@samsartor.com>
2026-01-05 01:45:27 +00:00
restitux cd90cb628b ci: correct windows dockerfile path
Build Mumble Web 2 / linux_build (push) Successful in 1m41s
Build Mumble Web 2 / windows_build (push) Successful in 2m31s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 17s
2025-12-05 14:24:43 -07:00
restitux 37613a65c4 web: fix audio on firefox
Build Mumble Web 2 / windows_build (push) Successful in 2m39s
Build Mumble Web 2 / linux_build (push) Successful in 4m57s
2025-12-05 01:54:12 -07:00
sam d6b482528f Load status from relative url (#5)
Build Mumble Web 2 / linux_build (push) Successful in 2m23s
Build Mumble Web 2 / windows_build (push) Successful in 2m33s
Remove public_url config option
Use proxy_url instead for example configs
Get status from relative endpoint, like /config
Show version on login page

Reviewed-on: #5
Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2025-12-05 07:00:38 +00:00
sam 5df7b0e082 Fix audio on windows (#3)
Build Mumble Web 2 / linux_build (push) Successful in 1m42s
Build Mumble Web 2 / windows_build (push) Successful in 2m43s
Reviewed-on: #3

Automatically choose supported profile
Play stream once created

Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2025-12-05 05:34:07 +00:00
sam 55412f5778 Revert 0.7.2 upgrade
Build Mumble Web 2 / linux_build (push) Successful in 1m39s
Build Mumble Web 2 / windows_build (push) Successful in 2m52s
2025-12-04 22:21:37 -07:00
sam 2982a7f8d8 Finish 0.7.2 upgrade
Build Mumble Web 2 / linux_build (push) Failing after 58s
Build Mumble Web 2 / windows_build (push) Successful in 2m29s
2025-12-04 22:17:15 -07:00
sam b1970cf23f Upgrade dioxus to 0.7.2
Build Mumble Web 2 / windows_build (push) Has been cancelled
Build Mumble Web 2 / linux_build (push) Has been cancelled
2025-12-04 22:16:02 -07:00
restitux a11fb4f10e Windows Desktop Client CI (#2)
Build Mumble Web 2 / linux_build (push) Successful in 1m43s
Build Mumble Web 2 / windows_build (push) Successful in 2m38s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Failing after 11s
Adds a windows container build and a windows client build.
Outstanding issues:
- I'm not sure if `workflow_dispatch` works. Based on [this PR](https://github.com/go-gitea/gitea/pull/28163) it seems like it should, but I don't see a button. It might only work after merging into the default branch.
- The windows build container is building dioxus from git HEAD because there is an unreleased bugfix we are depending on. We should revert this to a versioned release (maybe using `binstall`) once they cut 0.7.2

Reviewed-on: #2
2025-12-05 03:58:11 +00:00
sam 40bb8e18ef upgrade to dioxus 0.7.1
Build dioxus container / BuildContainer (push) Successful in 3m10s
2025-11-27 22:39:18 -07:00
restitux 128791bccd Add CI and artifact publishing (#1)
Build dioxus container / BuildContainer (push) Successful in 2m23s
Reviewed-on: #1
Reviewed-by: Sam Sartor <cap@samsartor.com>
2025-11-28 05:24:11 +00:00
sam e8d41f7e92 make status display live 2025-11-27 19:31:02 -07:00
sam 1e28442356 consistant line color 2025-11-27 18:02:30 -07:00
sam c645722d21 increase playback buffer size on desktop 2025-11-27 17:33:25 -07:00
restitux b9e293cca4 [cicd] add docker build workflow 2025-11-09 01:19:09 -07:00
restitux 97bdb1a143 [meta] add leading slash to .gitignored config.toml to fix import errors
WHAT THE FUCK
2025-11-09 01:17:50 -07:00
sam 70634065ac fix some problems with url origins in web (denoising works there too) 2025-10-28 01:44:28 -06:00
sam 1efd32892e denoising actually works on desktop! 2025-10-28 01:20:59 -06:00
sam 1ff302816e load an actual denoising model 2025-10-28 01:10:35 -06:00
sam ebcf5ce4ce fix some dependencies 2025-10-27 22:49:49 -06:00
sam 4e30be3ebd switch to scss asset & remove sir 2025-10-27 19:04:33 -06:00
sam 987cfd57d2 audio seems fine now 2025-10-26 19:05:48 -06:00
sam fea6800bea desktop recording sorta works 2025-10-26 18:55:09 -06:00
restitux f2bdc665f5 add status requesting to frontend 2025-10-26 01:34:25 -06:00
restitux 61f3a4e623 add status endpoint to config and update proxy to return them 2025-10-26 01:34:25 -06:00
sam 260decc9af try to run denoising 2025-10-26 00:19:16 -06:00
restitux cfb8144561 add /status endpoint to proxy 2025-10-25 23:52:38 -06:00
sam b8a201911f further simplify proxy config 2025-10-25 21:28:58 -06:00
sam 134e42e69f simplify proxy and update readme 2025-10-25 20:21:02 -06:00
sam 55a91b1459 actually read the config maybe 2025-10-25 20:03:19 -06:00
sam d9695be153 proper reactivity on config load 2025-10-25 19:42:08 -06:00
restitux 20ec64cf1c Update README with docker dev workflow 2025-07-13 19:36:33 -06:00
restitux 1793504467 add /config endpoint, add docker proxy setup, and style chat box 2025-07-13 19:33:55 -06:00
restitux 74fe399cdc user control box with some styling 2025-04-06 18:08:09 -06:00
sam dd65b238d1 wip image/file sending 2025-02-12 00:57:38 -07:00
restitux de0e41ec85 tweak user pills 2025-02-11 23:31:57 -07:00
sam 0462340694 our own message html processing to open links in new tab 2025-02-11 23:17:39 -07:00
sam 0b928c171f internal gencert working 2025-02-11 22:45:07 -07:00
sam a98bc825f6 wip proxy gencert internal 2025-02-11 22:06:12 -07:00
sam 980e8c2620 bump dioxus version 2025-02-11 20:24:42 -07:00
sam bcd73ae83f audio playback kinda works 2024-11-12 20:05:44 -07:00
sam b2ee911c66 attempt desktop audio playback 2024-11-12 20:03:58 -07:00
sam b65ec274d8 remove gui basepath 2024-11-12 20:03:27 -07:00
sam a25cf64681 salvo is working 2024-11-12 15:42:01 -07:00
sam 3c6a436690 wip salvo server 2024-11-11 17:14:53 -07:00
sam 80aedc7269 make proxy part of the project structure 2024-11-11 14:35:04 -07:00
sam efe842f671 Add 'proxy/' from commit 'e1f3bca708f1f5e8ecadc2becb95360a5a9ada13'
git-subtree-dir: proxy
git-subtree-mainline: 70fcd18690
git-subtree-split: e1f3bca708
2024-11-11 14:10:38 -07:00
sam 70fcd18690 actually make logging work 2024-11-11 14:09:05 -07:00
sam 2211be5324 bump dioxus version & icons in desktop & logging 2024-11-11 13:59:11 -07:00
sam 30a94323b3 move into gui folder for monorepo 2024-11-11 12:24:51 -07:00
sam e1f3bca708 built-in https 2024-11-09 14:34:12 -07:00
sam 7308a210e2 respect gui config 2024-11-09 14:08:37 -07:00
sam 105deab45d use config.toml & send gui config to client 2024-11-09 14:08:16 -07:00
restitux 4055bf24ab Add injected config file 2024-11-09 13:27:27 -07:00
restitux 95c57c4850 Add bundle script 2024-11-09 13:27:15 -07:00
restitux b19f629605 Add static asset serving 2024-11-09 12:50:56 -07:00
restitux 1d8f3fd791 cleanup 2024-08-30 23:18:22 -06:00
restitux 206bf23bdf Fix imports and try_write 2024-05-21 23:52:44 -06:00
restitux 2d3f31754b Add TCP connection to server 2024-05-21 23:29:13 -06:00
sam abd2a2f81c forgot length check 2024-05-21 18:10:17 -04:00
restitux b2cae01bf8 Add debug logging and global connection map 2024-05-21 00:22:41 -06:00
restitux 725db06703 Initial commit...it works? 2024-05-20 00:11:15 -06:00
53 changed files with 8970 additions and 3117 deletions
+14
View File
@@ -1,2 +1,16 @@
[build]
rustflags = ["--cfg=web_sys_unstable_apis"]
[profile]
[profile.dioxus-wasm]
inherits = "dev"
opt-level = 2
[profile.dioxus-server]
inherits = "dev"
opt-level = 2
[profile.dioxus-android]
inherits = "dev"
opt-level = 2
@@ -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
+108
View File
@@ -0,0 +1,108 @@
name: Build Mumble Web 2
on: [push]
jobs:
linux_build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: wasm32-unknown-unknown
- 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.2
#- uses: Swatinem/rust-cache@v2
- name: Build dioxus project
run: dx build --platform web --release -p mumble-web2-gui
- name: Upload mumble-web2-gui Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-gui
path: target/dx/mumble-web2-gui/release/web/public
retention-days: 5
- name: Build proxy
run: cargo build --release -p mumble-web2-proxy
- name: Upload mumble-web2-proxy Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-proxy
path: target/release/mumble-web2-proxy
retention-days: 5
windows_build:
runs-on: windows
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: Pull builder container
run: docker pull git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest
- name: Bundle dioxus project
run: docker run `
--mount "type=bind,source=${PWD},target=C:\app" `
--workdir "C:\app\gui" `
git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest `
C:\Users\ContainerAdministrator\.cargo\bin\dx.exe bundle --verbose --trace -p mumble-web2-gui --release --windows
- 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 Windows Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-gui-windows
path: gui/dist
retention-days: 5
android_build:
runs-on: ubuntu-latest
container:
image: git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest
steps:
- name: Checkout
uses: actions/checkout@v5
- uses: Swatinem/rust-cache@v2
- name: Build dioxus project (x86_64-linux-android)
run: dx build --platform android --target x86_64-linux-android --release -p mumble-web2-gui
- name: Build dioxus project (aarch64-linux-android)
run: dx build --platform android --target aarch64-linux-android --release -p mumble-web2-gui
- name: Upload mumble-web2-gui Android Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-android
path: target/dx/mumble-web2-gui/release/android/app/app/build/outputs/apk/debug/app-debug.apk
retention-days: 5
@@ -0,0 +1,27 @@
name: Build Mumble Web 2 release builder containers
on:
workflow_dispatch:
schedule:
- cron: "0 4 * * *"
jobs:
windows-release-builder-container-build:
runs-on: windows
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 Windows image
shell: bash
run: |
docker pull "$(grep -m1 '^FROM' ./docker/windows-release-builder.Dockerfile | awk '{print $2}')"
docker build -t git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest -f ./docker/windows-release-builder.Dockerfile .
docker push git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest
+6
View File
@@ -1,3 +1,9 @@
/target
dist/
server_hash.txt
.aider*
**.pem
proxy/bundle
/config.toml
proxy/config.toml
gui/assets/*_onnx.tar.gz
+2
View File
@@ -0,0 +1,2 @@
[language-server.rust-analyzer]
config = { cargo = { features = "all" } }
+4
View File
@@ -0,0 +1,4 @@
{
"rust-analyzer.cargo.features": ["desktop","web"],
"rust-analyzer.cargo.noDefaultFeatures": false
}
Generated
+5029 -1837
View File
File diff suppressed because it is too large Load Diff
+23 -87
View File
@@ -1,90 +1,26 @@
[package]
name = "mumble-web2"
version = "0.1.0"
edition = "2021"
[workspace]
resolver = "2"
members = ["common", "gui", "proxy"]
[dependencies]
dioxus = { version = "0.5.6" }
dioxus-web = { version = "0.5.6", optional = true }
manganis = "0.2.2"
once_cell = "1.19.0"
[workspace.dependencies]
serde = { version = "1.0.214", features = ["derive"] }
asynchronous-codec = "0.6.2"
futures = "0.3.30"
merge-io = "0.3.0"
mumble-protocol = { version = "0.5.0", package = "mumble-protocol-2x", default-features = false, features = [
"asynchronous-codec",
] }
serde_json = "1.0.117"
tokio-util = { version = "0.7.11", features = ["codec", "compat"] }
wasm-bindgen = { version = "0.2.92", optional = true }
wasm-bindgen-futures = { version = "0.4.42", optional = true }
wasm-streams = { version = "0.4.0", optional = true }
serde-wasm-bindgen = { version = "0.6.5", optional = true }
js-sys = { version = "0.3.70", optional = true }
web-sys = { version = "0.3.72", features = [
"WebTransport",
"console",
"WebTransportOptions",
"WebTransportBidirectionalStream",
"WebTransportSendStream",
"WebTransportReceiveStream",
"Navigator",
"MediaDevices",
"AudioDecoder",
"AudioDecoderInit",
"AudioData",
"AudioEncoderConfig",
"AudioDecoderConfig",
"EncodedAudioChunk",
"EncodedAudioChunkInit",
"EncodedAudioChunkType",
"CodecState",
"MediaStreamTrackGenerator",
"MediaStreamTrackGeneratorInit",
"AudioContext",
"AudioContextOptions",
"MediaStream",
"GainNode",
"MediaStreamAudioSourceNode",
"BaseAudioContext",
"AudioDestinationNode",
"AudioWorkletNode",
"AudioWorklet",
"AudioWorkletProcessor",
"MediaStreamConstraints",
"WorkletOptions",
"AudioEncoder",
"AudioEncoderInit",
"AudioDataInit",
"HtmlAnchorElement",
"Url",
"Blob",
"AudioDataCopyToOptions",
"AudioSampleFormat",
"Storage",
], optional = true}
anyhow = "1.0.86"
byteorder = "1.5.0"
ogg = "0.9.1"
ordermap = "0.5.3"
html-purifier = "0.3.0"
markdown = "0.3.0"
gloo-timers = { version = "0.3.0", features = ["futures"], optional = true }
futures-channel = "0.3.30"
sir = { version = "0.5.0", features = ["dioxus"] }
tokio = { version = "1.41.1", features = ["net", "rt"], optional = true }
tokio-rustls = { version = "0.26.0", optional = true }
mumble-web2-common = { path = "common" }
[features]
web = [
"dioxus/web",
"dioxus-web",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"serde-wasm-bindgen",
"js-sys",
"web-sys",
"gloo-timers",
]
desktop = ["dioxus/desktop", "tokio", "tokio-rustls"]
[workspace.dependencies.mumble-protocol]
version = "0.5.0"
package = "mumble-protocol-2x"
default-features = false
features = ["asynchronous-codec"]
[profile]
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
[profile.server-dev]
inherits = "dev"
[profile.android-dev]
inherits = "dev"
+22
View File
@@ -0,0 +1,22 @@
# GUI Development
## Running Desktop
1. `cargo install dioxus-cli --version 0.7.1`
2. `dx run -p mumble-web2-gui --platform desktop --release`
## Running Web (development)
1. `cargo install dioxus-cli --version 0.7.1`
3. `dx serve -p mumble-web2-gui --platform web`
2. `cd docker && docker compose up`
4. connect to `https://localhost:64444`
5. fill in the proxy url as `https://127.0.0.1:4433/proxy` (this should autofill)
## Running Web (with `proxy` only)
1. `cargo install dioxus-cli --version 0.7.1`
2. `dx build -p mumble-web2-gui --platform web --release`
3. `cp config.toml.example config.toml`
4. `cargo run -p mumble-web2-proxy` in the background
5. connect to `localhost:8080`
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "mumble-web2-common"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
+18
View File
@@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ClientConfig {
pub proxy_url: Option<String>,
pub cert_hash: Option<Vec<u8>>,
pub any_server: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ServerStatus {
#[serde(default)]
pub success: bool,
pub version: Option<(u32, u32, u32)>,
pub users: Option<u32>,
pub max_users: Option<u32>,
pub bandwidth: Option<u32>,
}
+5
View File
@@ -0,0 +1,5 @@
proxy_url = "https://127.0.0.1:4433/proxy"
https_listen_address = "127.0.0.1:4433"
http_listen_address = "127.0.0.1:8080"
mumble_server_url = "[SERVER_URL_HERE]"
gui_path = "target/dx/mumble-web2-gui/release/web/public"
+14
View File
@@ -0,0 +1,14 @@
localhost:64444 {
tls internal
# Proxy /config path to mumble-web2-proxy
reverse_proxy /config http://127.0.0.1:4400
# Proxy /status path to mumble-web2-proxy
reverse_proxy /status http://127.0.0.1:4400
# Proxy root path to dx-serve
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.2
# 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"
+58
View File
@@ -0,0 +1,58 @@
services:
caddy:
image: caddy:latest
ports:
- "64444:64444/tcp"
- "64444:64444/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:z
#- caddy_data:/data
#- caddy_config:/config
depends_on:
#- dx-serve
- mumble-web2-proxy
network_mode: host
#dx-serve:
# build:
# dockerfile: ./dioxus.Dockerfile
# working_dir: /app
# volumes:
# - ..:/app
# environment:
# - MUMBLE_WEB2_GUI_CONFIG_URL=https://localhost:64444/config
# stdin_open: true
# tty: true
# command: >
# bash -c "
# screen -dmS serve bash -c 'dx serve -p mumble-web2-gui --platform web' &&
# tail -f /dev/null
# "
# networks:
# - app-network
mumble-web2-proxy:
image: rust:latest
working_dir: /app
volumes:
- ..:/app:z
- ./proxy-config.toml:/app/config.toml:z
ports:
- "4433:4433/tcp"
- "4433:4433/udp"
command: ["cargo", "run", "-p", "mumble-web2-proxy", "--locked"]
network_mode: host
mumble-server:
image: mumblevoip/mumble-server:latest
ports:
- "64738:64738/tcp"
- "64738:64738/udp"
network_mode: host
#volumes:
# caddy_data:
# caddy_config:
#
#networks:
# app-network:
# driver: bridge
+4
View File
@@ -0,0 +1,4 @@
proxy_url = "https://127.0.0.1:4433/proxy"
https_listen_address = "127.0.0.1:4433"
http_listen_address = "127.0.0.1:4400"
mumble_server_url = "127.0.0.1:64738"
+57
View File
@@ -0,0 +1,57 @@
# escape=`
# Use a Windows Server Core 2025 image that matches our build host.
# If the version doesn't match the build host we cannot run
# this container. I'm not sure with what specificity it has to
# match, so let's pin this and then upgrade it as we upgrade
# the host.
FROM mcr.microsoft.com/windows/servercore:10.0.26100.7171
ENV CMAKE_VERSION=3.31.10
ENV CMAKE_ARCH=windows-x86_64
# Restore the default Windows shell for correct batch processing.
SHELL ["cmd", "/S", "/C"]
RUN curl -SL --output vs_buildtools.exe https://aka.ms/vs/17/release/vs_buildtools.exe `
&& ( start /w vs_buildtools.exe --quiet --wait --norestart --nocache `
--installPath "%ProgramFiles(x86)%\Microsoft Visual Studio\2022\BuildTools" `
--add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
--add Microsoft.VisualStudio.Component.Windows10SDK.19041 `
--add Microsoft.VisualStudio.Workload.NativeDesktop `
|| IF "%ERRORLEVEL%"=="3010" EXIT 0 )
SHELL ["powershell", "-NoLogo", "-ExecutionPolicy", "Bypass", "-Command"]
RUN $ErrorActionPreference = 'Stop'; `
$url = \"https://github.com/Kitware/CMake/releases/download/v$env:CMAKE_VERSION/cmake-$env:CMAKE_VERSION-$env:CMAKE_ARCH.zip\"; `
$out = 'C:\\cmake.zip'; `
(New-Object System.Net.WebClient).DownloadFile($url, $out); `
Expand-Archive -Path $out -DestinationPath 'C:\\'; `
Remove-Item $out; `
$cmakeDir = \"C:\\cmake-$env:CMAKE_VERSION-$env:CMAKE_ARCH\\bin\"; `
[Environment]::SetEnvironmentVariable('PATH', $cmakeDir + ';' + $env:PATH, 'Machine')
# Install Chocolatey
RUN Set-ExecutionPolicy Bypass -Scope Process; `
[System.Net.ServicePointManager]::SecurityProtocol = `
[System.Net.SecurityProtocolType]::Tls12; `
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
RUN choco install git -y --no-progress
RUN choco install rustup.install -y --no-progress
RUN rustup toolchain install stable-x86_64-pc-windows-msvc
RUN rustup default stable-x86_64-pc-windows-msvc
# Install carog binstall
RUN Set-ExecutionPolicy Unrestricted -Scope Process; `
iex (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
# Install dioxus-cli from git HEAD with cargo
# This is to work around a bug in the windows builder upstream.
# Dioxus has released 0.7.2, but it seems to be broken for now.
RUN cargo binstall dioxus-cli
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
+158
View File
@@ -0,0 +1,158 @@
[package]
name = "mumble-web2-gui"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web Dependencies
# ================
dioxus-web = { version = "0.7.1", optional = true }
wasm-bindgen = { version = "^0.2.92", optional = true }
wasm-bindgen-futures = { version = "^0.4.42", optional = true }
wasm-streams = { version = "^0.4.0", optional = true }
serde-wasm-bindgen = { version = "^0.6.5", optional = true }
js-sys = { version = "^0.3.70", optional = true }
web-sys = { version = "^0.3.72", features = [
"WebTransport",
"console",
"WebTransportOptions",
"WebTransportBidirectionalStream",
"WebTransportSendStream",
"WebTransportReceiveStream",
"Navigator",
"MediaDevices",
"AudioDecoder",
"AudioDecoderInit",
"AudioData",
"AudioEncoderConfig",
"AudioDecoderConfig",
"EncodedAudioChunk",
"EncodedAudioChunkInit",
"EncodedAudioChunkType",
"CodecState",
"AudioContext",
"AudioContextOptions",
"MediaStream",
"GainNode",
"MediaStreamAudioSourceNode",
"BaseAudioContext",
"AudioDestinationNode",
"AudioWorkletNode",
"AudioWorklet",
"AudioWorkletProcessor",
"MessagePort",
"MediaStreamConstraints",
"WorkletOptions",
"AudioEncoder",
"AudioEncoderInit",
"AudioDataInit",
"HtmlAnchorElement",
"Url",
"Blob",
"AudioDataCopyToOptions",
"AudioSampleFormat",
"Storage",
], optional = true }
gloo-timers = { version = "^0.3.0", features = ["futures"], optional = true }
tracing-web = { version = "^0.1.3", optional = true }
# Desktop Dependecies
# ===================
tokio = { version = "^1.41.1", features = ["net", "rt"], optional = true }
tokio-rustls = { version = "^0.26.0", optional = true }
opus = { version = "0.3.0", optional = true }
cpal = { version = "0.15.3", optional = true }
dasp_ring_buffer = { version = "0.11.0", optional = true }
etcetera = { version = "0.10.0", optional = true }
# Base Dependencies
# ================
dioxus = { version = "0.7.2" }
once_cell = "1.19.0"
asynchronous-codec = { workspace = true }
futures = "^0.3.30"
merge-io = "^0.3.0"
mumble-protocol = { workspace = true }
serde_json = "1"
tokio-util = { version = "^0.7.11", features = ["codec", "compat"] }
byteorder = "1.5"
ogg = "^0.9.1"
ordermap = "^0.5.3"
html-purifier = "^0.3.0"
markdown = "^0.3.0"
futures-channel = "^0.3.30"
mumble-web2-common = { workspace = true }
serde = { workspace = true }
tracing-subscriber = { version = "^0.3.18", features = ["ansi"] }
tracing = "^0.1.40"
color-eyre = "^0.6.3"
crossbeam-queue = "^0.3.11"
lol_html = "^2.2.0"
base64 = "^0.22"
mime_guess = "^2.0.5"
async_cell = "^0.2.3"
reqwest = { version = "^0.12.22", features = ["json"] }
dioxus-asset-resolver = "0.7.2"
# Denoising
# =========
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
"tract",
] }
crossbeam = "0.8.4"
# Platform Integration
# ====================
# rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true }
# Android dependencies for requesting permissions
[target.'cfg(target_os = "android")'.dependencies]
android-permissions = "0.1.2"
jni = "0.21.1"
ndk-context = "0.1.1"
[patch.crates-io]
tract-hir = "=0.12.4"
tract-core = "=0.12.4"
tract-onnx = "=0.12.4"
tract-pulse = "=0.12.4"
[features]
web = [
"dioxus/web",
"dioxus-web",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"serde-wasm-bindgen",
"js-sys",
"web-sys",
"gloo-timers",
"tracing-web",
"deep_filter/wasm",
"rfd",
]
desktop = [
"dioxus/desktop",
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
"rfd/xdg-portal",
"etcetera",
]
mobile = [
"dioxus/mobile",
"tokio",
"tokio-rustls",
"tracing-subscriber/env-filter",
"opus",
"cpal",
"dasp_ring_buffer",
]
+16 -3
View File
@@ -8,22 +8,24 @@ out_dir = "dist"
# resource (public) file folder
asset_dir = "public"
android_manifest = "build/AndroidManifest.xml"
[web.app]
# HTML title tag content
title = "mumble-web"
title = "Mumble Web 2"
[web.watcher]
# when watcher trigger, regenerate the `index.html`
reload_html = true
# which files or dirs will be watcher monitoring
watch_path = ["src", "public"]
watch_path = ["src", "assets"]
# include `assets` in web platform
[web.resource]
# CSS style file
style = []
# Javascript code file
script = []
script = ["loader.js"]
[web.resource.dev]
# serve: [dev-server] only
@@ -31,3 +33,14 @@ script = []
style = []
# Javascript code file
script = []
[bundle]
identifier = "xyz.ohea.mumble_web_2"
publisher = "OheaCorp"
icon = [
"icons/32x32.png",
"icons/256x256.png",
"assets/favicon.ico",
"icons/icon.icns",
"icons/icon.ico",
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

+395
View File
@@ -0,0 +1,395 @@
:root {
--txt-color: oklch(0.9 0 99);
--bg-color: oklch(0.15 0.01 338.64);
--light-bg-color: oklch(0.25 0.01 338.64);
--login-bg-color: #5d7680;
--primary-btn-color: #7bad9f;
--accent-normal: #7bad9f;
--accent-muted: #ff746c;
--accent-deafened: #464459;
--line-width: 2px;
--line-color: oklch(0.7 0 0.99);
}
body {
margin: 0;
}
#main {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-around;
background-color: var(--bg-color);
overflow: auto;
color: var(--txt-color);
font-family: Nunito;
font-size: 15pt;
font-weight: 600;
}
hr {
color: var(--line-color);
background-color: var(--line-color);
height: var(--line-width);
border: none;
}
button {
font-weight: bold;
font-size: medium;
border: none;
border-radius: 4px;
color: var(--txt-color);
background-color: var(--primary-btn-color);
cursor: pointer;
}
input {
border: none;
border-radius: 4px;
background-color: white;
color: black;
}
input:focus,
input:focus-visible {
border: none;
outline: solid var(--line-width) var(--accent-normal);
outline-offset: -3px;
}
a:link {
color: var(--accent-normal);
}
a:visited {
color: var(--accent-muted);
}
.userpil {
border-radius: 100px;
padding: 4px 8px;
width: fit-content;
img {
height: 1em;
vertical-align: text-bottom;
}
&.is_self {
font-weight: bolder;
}
}
.channel {
&_details {
flex: 0 0 100%;
summary {
cursor: pointer;
}
summary:focus-visible {
outline: none;
}
}
&_children {
border-left: solid var(--line-color) var(--line-width);
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-left: 5px;
padding-left: 11px;
padding-top: 4px;
}
}
.chat {
&_panel {
display: flex;
flex-direction: column;
}
&_history {
overflow-y: auto;
flex: 1 0 0;
}
&_message {
display: flex;
flex-direction: row;
margin: 16px;
gap: 8px;
align-items: center;
}
&_box_wrapper {
padding: 16px;
border-top: solid var(--line-color) var(--line-width);
}
&_box {
display: flex;
flex-direction: row;
gap: 16px;
background-color: var(--light-bg-color);
padding-top: 16px;
padding-bottom: 16px;
padding-left: 8px;
padding-right: 16px;
border-radius: 8px;
input {
color: white;
background-color: var(--light-bg-color);
font-size: larger;
flex-grow: 1;
border: none;
}
input:focus {
outline: none;
}
}
}
.user_edit_button {
background-color: oklch(0.53 0.1431 264.18);
border-radius: 50%;
aspect-ratio: 1 / 1;
flex-shrink: 0;
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
}
}
.button_row {
display: flex;
gap: clamp(4px, 1vw, 10px);
align-items: center;
flex-wrap: nowrap;
min-height: 0;
.spacer {
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
}
.connection_status {
display: flex;
flex-direction: column;
min-width: 0;
flex-shrink: 1;
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
vertical-align: middle;
}
}
.user_info {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
flex-shrink: 1;
.user_name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user_data {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.toggle_button {
padding: clamp(4px, 0.5vw, 8px);
aspect-ratio: 1 / 1;
flex-shrink: 0;
background-color: unset;
border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px);
border-radius: clamp(4px, 0.8vw, 10px);
color: rgb(255 255 255 / 50%);
transition: all 0.5s ease-in-out;
&.is_on {
background-color: oklch(0.5 0.1381 21.71 / 20.12%);
color: oklch(0.53 0.1505 21.71 / 89.38%);
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
vertical-align: middle;
}
}
.server {
&_grid {
display: grid;
height: 100%;
background-color: var(--bg-color);
grid-template-rows: 1fr auto;
grid-template-columns: 1fr 2fr;
grid-template-areas:
"tree chat"
"control chat";
@media screen and (max-width: 720px) {
grid-template-rows: auto 1fr 1fr;
grid-template-columns: 1fr;
grid-template-areas:
"tree"
"control"
"chat";
}
}
&_channel_box {
padding: 16px;
overflow: auto;
grid-area: tree;
}
&_chat_box {
display: flex;
flex-direction: row;
grid-area: chat;
border-left: solid var(--line-color) var(--line-width);
@media screen and (max-width: 720px) {
border-left: unset;
border-top: solid var(--line-color) var(--line-width);
}
}
&_control_box {
padding: clamp(6px, 0.8vw, 12px);
margin: clamp(6px, 0.8vw, 12px);
background-color: var(--light-bg-color);
border-radius: clamp(6px, 1vw, 10px);
overflow: hidden;
grid-area: control;
display: flex;
gap: clamp(4px, 0.8vw, 8px);
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 {
font-size: 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 {
font-size: 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 {
font-size: var(--toggle-icon-size);
}
}
hr {
margin: 0;
}
}
}
.login {
max-width: 50vw;
align-self: center;
padding: 32px;
border-radius: 16px;
background-color: var(--login-bg-color);
display: flex;
flex-direction: column;
gap: 16px;
input,
button {
padding: 8px;
}
h1 {
margin: 0;
color: #b3c6b4;
}
&_version {
color: var(--txt-color);
font-weight: normal;
}
&_bttn {
font-weight: bold;
font-size: large;
}
&_error {
background-color: white;
border-radius: 4px;
overflow: auto;
padding: 4px;
color: red;
pre {
color: black;
}
}
&_status {
&.is_error {
color: red;
}
}
}

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,7 +1,7 @@
const SAMPLE_RATE = 48000;
const PACKET_SAMPLES = 960;
class RustWorklet extends AudioWorkletProcessor {
class RustMicWorklet extends AudioWorkletProcessor {
constructor(options) {
super();
this.module = options.processorOptions;
@@ -31,7 +31,7 @@ class RustWorklet extends AudioWorkletProcessor {
}
this.buffer_offset -= PACKET_SAMPLES;
this.timestamp = null;
}
}
process(inputs) {
//console.log(inputs);
@@ -60,4 +60,44 @@ class RustWorklet extends AudioWorkletProcessor {
}
};
registerProcessor("rust_mic_worklet", RustWorklet);
class RustSpeakerWorklet extends AudioWorkletProcessor {
constructor() {
super();
this.queue = [];
this.readIndex = 0;
this.port.onmessage = (event) => {
this.queue.push(event.data)
};
}
process(inputs, outputs) {
if (this.queue.length) {
console.log(this.queue[0].samples.length, outputs[0][0].length);
}
const output = outputs[0];
for (let i = 0; i < output[0].length; i++) {
if (!this.queue.length) {
return true;
}
const current = this.queue[0];
for (let ch = 0; ch < output.length; ch++) {
output[ch][i] = current.samples[this.readIndex];
}
this.readIndex++;
if (this.readIndex >= current.samples.length) {
this.queue.shift();
this.readIndex = 0;
}
}
return true;
}
};
registerProcessor("rust_mic_worklet", RustMicWorklet);
registerProcessor("rust_speaker_worklet", RustSpeakerWorklet);

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 737 B

After

Width:  |  Height:  |  Size: 737 B

+86
View File
@@ -0,0 +1,86 @@
use std::env;
use std::path::Path;
use std::process::Command;
fn version_env() -> Option<()> {
if env::var("MUMBLE_WEB2_VERSION").is_ok() {
return Some(());
}
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
let git_hash = String::from_utf8(output.stdout).ok()?;
let git_hash = git_hash.trim(); // drop trailing newline
let status = Command::new("git")
.args(["status", "--porcelain"])
.output()
.ok()?;
let dirty = match status.stdout.is_empty() {
true => "",
false => "-dirty",
};
// Expose it as a compile-time env var
println!("cargo::rustc-env=MUMBLE_WEB2_VERSION=git-{git_hash}{dirty}");
// Optional: rebuild when HEAD changes
println!("cargo::rerun-if-changed=.git/HEAD");
Some(())
}
fn download_deepfilternet() {
// Define the target directory and file
let assets_dir = "assets";
let target_file = format!("{}/DeepFilterNet3_ll_onnx.tar.gz", assets_dir);
let target_path = Path::new(&target_file);
// Check if the file already exists
if target_path.exists() {
println!(
"cargo::warning=DeepFilterNet model already exists at {}",
target_file
);
return;
}
println!(
"cargo::warning=Downloading DeepFilterNet model to {}...",
target_file
);
// Download the file using curl
let url = "https://github.com/Rikorose/DeepFilterNet/raw/refs/heads/main/models/DeepFilterNet3_ll_onnx.tar.gz";
let status = Command::new("curl")
.args([
"-L", // Follow redirects
"-o",
&target_file, // Output file
url,
])
.status()
.expect("Failed to execute curl command. Make sure curl is installed.");
if !status.success() {
println!("cargo::error=Failed to download DeepFilterNet model from {url}");
return;
}
println!(
"cargo::warning=Successfully downloaded DeepFilterNet model to {}",
target_file
);
// Rerun this build script if the target file is deleted
println!("cargo::rerun-if-changed={}", target_file);
}
fn main() {
version_env();
download_deepfilternet();
}
+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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 B

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

+68
View File
@@ -0,0 +1,68 @@
// Loading screen that displays while WASM loads
(function() {
// Create and inject loader styles immediately (head exists)
var style = document.createElement('style');
style.textContent =
'.wasm-loader {' +
'position: fixed;' +
'top: 0;' +
'left: 0;' +
'width: 100%;' +
'height: 100%;' +
'background-color: oklch(0.15 0.01 338.64);' +
'display: flex;' +
'align-items: center;' +
'justify-content: center;' +
'z-index: 9999;' +
'transition: opacity 0.3s ease-out;' +
'}' +
'.wasm-loader.hidden {' +
'opacity: 0;' +
'pointer-events: none;' +
'}' +
'.wasm-spinner {' +
'width: 48px;' +
'height: 48px;' +
'border: 4px solid rgba(123, 173, 159, 0.2);' +
'border-top-color: #7bad9f;' +
'border-radius: 50%;' +
'animation: wasm-spin 1s linear infinite;' +
'}' +
'@keyframes wasm-spin {' +
'to { transform: rotate(360deg); }' +
'}' +
'#main {' +
'background-color: oklch(0.15 0.01 338.64);' +
'}';
document.head.appendChild(style);
function init() {
// Create loader element
var loader = document.createElement('div');
loader.className = 'wasm-loader';
loader.innerHTML = '<div class="wasm-spinner"></div>';
document.body.appendChild(loader);
// Watch for Dioxus to mount content in #main
var observer = new MutationObserver(function(mutations, obs) {
var main = document.getElementById('main');
if (main && main.children.length > 0) {
loader.classList.add('hidden');
setTimeout(function() { loader.remove(); }, 300);
obs.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Wait for body to exist
if (document.body) {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
})();
+745
View File
@@ -0,0 +1,745 @@
#![allow(non_snake_case)]
use dioxus::prelude::*;
use mime_guess::Mime;
use mumble_web2_common::{ClientConfig, ServerStatus};
use ordermap::OrderSet;
use std::collections::HashMap;
use crate::imp;
pub type ChannelId = u32;
pub type UserId = u32;
pub enum ConnectionState {
Disconnected,
Connecting,
Connected,
Failed(String),
}
#[derive(Debug)]
pub enum Command {
Connect {
address: String,
username: String,
config: ClientConfig,
},
SendChat {
markdown: String,
channels: Vec<ChannelId>,
},
SendFile {
bytes: Vec<u8>,
name: String,
mime: Option<Mime>,
channels: Vec<ChannelId>,
},
SetMute {
mute: bool,
},
SetDeaf {
deaf: bool,
},
EnterChannel {
channel: ChannelId,
user: UserId,
},
UpdateMicEffects {
denoise: bool,
},
Disconnect,
}
use Command::*;
use ConnectionState::*;
#[derive(Default)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
}
#[derive(Default)]
pub struct UserState {
pub name: String,
pub channel: ChannelId,
pub deaf: bool,
pub mute: bool,
pub suppress: bool,
pub self_deaf: bool,
pub self_mute: bool,
}
impl UserState {
pub fn icon(&self) -> UserIcon {
if self.deaf || self.self_deaf {
UserIcon::Deafened
} else if self.mute || self.self_mute {
UserIcon::Muted
} else if self.suppress {
UserIcon::Suppressed
} else {
UserIcon::Normal
}
}
}
pub struct Chat {
pub raw: String,
pub dangerous_html: String,
pub sender: Option<UserId>,
}
#[derive(Default)]
pub struct ServerState {
pub channels: HashMap<ChannelId, ChannelState>,
pub users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>,
pub session: Option<UserId>,
}
impl ServerState {
pub fn this_user(&self) -> Option<&UserState> {
self.users.get(&self.session?)
}
}
pub struct State {
pub status: GlobalSignal<ConnectionState>,
pub server: GlobalSignal<ServerState>,
}
pub static STATE: State = State {
status: Signal::global(|| Disconnected),
server: Signal::global(|| Default::default()),
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum UserIcon {
Normal,
Muted,
Deafened,
Suppressed,
None,
}
impl UserIcon {
pub fn url(self) -> Option<Asset> {
// speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/
// mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/
use UserIcon::*;
Some(match self {
Normal => asset!("assets/mic-svgrepo-com.svg"),
Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"),
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
None => return Option::None,
})
}
}
#[component]
pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
let color = match icon {
UserIcon::Normal => "var(--accent-normal)",
UserIcon::Muted => "var(--accent-muted)",
UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)",
UserIcon::None => "var(--accent-normal)",
};
rsx!(
div {
class: match isself { true => "userpil is_self", false => "userpil" },
style: "background-color: {color}",
{ icon.url().map(|url| rsx!(img { src: url })) }
"\u{00A0}{name}\u{00A0}"
}
)
}
#[component]
pub fn User(id: UserId) -> Element {
let server = STATE.server.read();
match server.users.get(&id) {
Some(state) => rsx!(UserPill {
name: state.name.clone(),
icon: state.icon(),
isself: server.session.unwrap() == id,
}),
None => rsx!(UserPill {
name: format!("unknown user ({id})"),
icon: UserIcon::None,
isself: false,
}),
}
}
#[component]
pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let user = server.session.unwrap();
let Some(state) = server.channels.get(&id) else {
return rsx!("missing channel {id}");
};
rsx!(
details {
class: "channel_details",
open: true,
summary {
span {
role: "button",
ondoubleclick: move |evt| {
evt.stop_propagation();
evt.prevent_default();
net.send(EnterChannel { channel: id, user })
},
"{state.name}"
}
}
if state.users.len() + state.children.len() > 0 {
div {
class: "channel_children",
for id in state.users.iter() {
User { id: *id }
}
for child in state.children.iter() {
Channel { id: *child }
}
}
}
}
)
}
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn pick_and_send_file(net: &Coroutine<Command>) {
let channels = if let Some(user) = STATE.server.read().this_user() {
vec![user.channel]
} else {
return;
};
let dialog = rfd::AsyncFileDialog::new().pick_file();
let sender = net.tx();
spawn(async move {
let Some(handle) = dialog.await else { return };
let name = handle.file_name();
let bytes = handle.read().await;
let mime = mime_guess::from_path(&name).first();
let _ = sender.unbounded_send(SendFile {
bytes,
name,
mime,
channels,
});
});
}
#[cfg(not(any(feature = "desktop", feature = "web")))]
pub fn pick_and_send_file(net: &Coroutine<Command>) {}
#[component]
pub fn ChatView() -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let mut draft = use_signal(|| "".to_string());
let mut do_send = move || {
if let Some(user) = STATE.server.read().this_user() {
net.send(SendChat {
markdown: draft.write().split_off(0),
channels: vec![user.channel],
});
}
};
rsx!(
div {
class: "chat_panel",
div {
class: "chat_history",
for chat in server.chat.iter() {
div {
class: "chat_message",
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
UserPill {
name: sender.name.clone(),
icon: UserIcon::None,
isself: false,
}
}
span {
dangerous_inner_html: "{chat.dangerous_html}",
}
}
}
}
div {
class: "chat_box_wrapper",
div {
class: "chat_box",
input {
placeholder: "say something",
value: "{draft.read()}",
oninput: move |evt| draft.set(evt.value().clone()),
onkeypress: move |evt: Event<KeyboardData>| {
if evt.code() == Code::Enter && evt.modifiers().is_empty() {
do_send();
}
}
}
div {
span {
onclick: move |_| pick_and_send_file(&net),
class: "material-symbols-outlined",
style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
"attach_file",
}
}
div {
span {
onclick: move |_| do_send(),
class: "material-symbols-outlined",
style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
"send",
}
}
}
//button {
// onclick: move |_| do_send(),
// "Send"
//}
}
}
)
}
#[component]
pub fn ControlView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let status = &STATE.status;
let server = STATE.server.read();
let Some(&UserState {
deaf,
self_deaf,
mute,
suppress,
self_mute,
ref name,
channel,
..
}) = server.this_user()
else {
return rsx!();
};
let current_channel_name = server.channels[&channel].name.clone();
let proxy_url = config
.read_unchecked()
.as_ref()
.and_then(|gui_config| gui_config.proxy_url.clone());
let connecting_color = "yellow";
let connected_color = "oklch(0.55 0.1184 141.35)";
let disconnected_color = "gray";
let failed_color = "red";
let connection_status = match &*status.read() {
Connecting => rsx! {
div {
class: "connection_status",
style: "color: {connecting_color};",
div {
span {
class: "material-symbols-outlined",
"signal_cellular_alt_2_bar"
}
span {
class: "status_text",
" Connecting"
}
}
}
},
Connected => rsx! {
div {
class: "connection_status",
div {
style: "color: {connected_color};",
span {
class: "material-symbols-outlined",
"signal_cellular_alt"
}
span {
class: "status_text",
" Connected"
}
}
div {
class: "channel_text",
span { "{current_channel_name}" }
}
}
},
Disconnected => rsx! {
div {
class: "connection_status",
style: "color: {disconnected_color};",
div {
span {
class: "material-symbols-outlined",
"signal_disconnected"
}
span {
class: "status_text",
" Disconnected"
}
}
}
},
Failed(_) => rsx! {
div {
class: "connection_status",
style: "color: {failed_color};",
div {
span {
class: "material-symbols-outlined",
"error"
}
span {
class: "status_text",
" Failed"
}
}
}
},
};
let denoise = use_signal(|| false);
rsx!(
// Server control
div {
class: "button_row",
div {
{connection_status}
}
span { class: "spacer" }
button {
class: "toggle_button",
onclick: move |_| net.send(Disconnect),
span {
class: "material-symbols-outlined",
"signal_disconnected"
}
}
}
hr { style: "width: 100%;" }
// User control
div {
class: "button_row",
button {
class: "user_edit_button",
span {
class: "material-symbols-outlined",
style: "color: oklch(0.65 0.2245 28.06);",
"person_edit"
}
}
div {
class: "user_info",
div {
span { class: "user_name", "{name}" }
}
div {
span { class: "user_data", "some data" }
}
}
span { class: "spacer" }
button {
class: match denoise() {
true => "toggle_button is_on",
false => "toggle_button",
},
role: "switch",
aria_checked: denoise(),
onclick: move |_| {
let new_denoise = !denoise();
*denoise.write_unchecked() = new_denoise;
net.send(UpdateMicEffects { denoise: new_denoise })
},
match denoise() {
true => rsx!(span { class: "material-symbols-outlined", "cadence"}),
false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}),
}
}
button {
class: match mute || suppress || self_mute {
true => "toggle_button is_on",
false => "toggle_button",
},
role: "switch",
aria_checked: mute || suppress || self_mute,
disabled: mute || suppress,
onclick: move |_| net.send(SetMute { mute: !self_mute }),
match mute || suppress || self_mute {
true => rsx!(span { class: "material-symbols-outlined", "mic_off"}),
false => rsx!(span { class: "material-symbols-outlined", "mic"}),
}
}
button {
class: match deaf || self_deaf {
true => "toggle_button in_on",
false => "toggle_button",
},
role: "switch",
aria_checked: deaf || self_deaf,
disabled: deaf,
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
match deaf || self_deaf {
true => rsx!(span { class: "material-symbols-outlined", "volume_off"}),
false => rsx!(span { class: "material-symbols-outlined", "volume_up"}),
}
}
}
)
}
#[component]
pub fn ServerView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let Some(&UserState {
deaf,
self_deaf,
mute,
self_mute,
..
}) = server.this_user()
else {
return rsx!();
};
rsx!(
div {
class: "server_grid",
div {
class: "server_channel_box",
for (id, state) in server.channels.iter() {
if state.parent.is_none() {
Channel { id: *id }
}
}
}
div {
class: "server_chat_box",
ChatView {}
}
div {
class: "server_control_box",
ControlView { config }
}
}
)
}
#[component]
pub fn LoginView(config: Resource<ClientConfig>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
use_resource(move || async move {
let client = reqwest::Client::new();
loop {
*last_status.write_unchecked() = Some(imp::get_status(&client).await);
imp::sleep(std::time::Duration::from_secs_f32(1.0)).await;
}
});
let mut address_input = use_signal(|| imp::load_server_url());
let address = use_memo(move || {
if let Some(addr) = address_input() {
addr.clone()
} else {
config()
.and_then(|c| c.proxy_url.clone())
.unwrap_or_default()
}
});
let previous_username = imp::load_username();
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
let do_connect = move |_| {
//let _ = set_default_username(&username.read());
let _ = imp::set_default_username(&username.read());
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
imp::set_default_server(&address.read());
}
net.send(Connect {
address: address.read().clone(),
username: username.read().clone(),
config: config.read().clone().unwrap_or_default(),
})
};
let status = &STATE.status;
let bottom = match &*status.read() {
Disconnected => rsx! {
button {
class: "login_bttn",
onclick: do_connect.clone(),
"Connect"
}
},
Connecting => rsx! {
div {
class: "login_bttn",
"Connecting..."
}
},
Failed(msg) => rsx!(
button {
class: "login_bttn",
onclick: do_connect.clone(),
"Reconnect"
}
div {
class: "login_error",
"Failed to connect:"
pre {
"{msg}"
}
}
),
Connected => unreachable!(),
};
let version = option_env!("MUMBLE_WEB2_VERSION");
rsx!(
div {
class: "login",
h1 {
"Mumble Web"
match version {
Some(v) => rsx!(" " span { class: "login_version", "({v})" }),
None => rsx!(),
}
}
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
div {
label {
for: "address-entry",
"Server Address:"
}
input {
id: "address-entry",
placeholder: "address",
value: "{address.read()}",
autofocus: "true",
oninput: move |evt| address_input.set(Some(evt.value().clone())),
}
}
}
div {
label {
for: "username-entry",
"Username:"
//style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
}
input {
id: "username-entry",
placeholder: "username",
value: "{username.read()}",
autofocus: "true",
oninput: move |evt| username.set(evt.value().clone()),
}
}
div {
match &*last_status.read() {
None => rsx!(div {
class: "login_status",
span {"···"}
}),
Some(Ok(ServerStatus { success: false, .. })) => rsx!(div {
class: "login_status is_error",
span {
"Could not reach server"
}
}),
Some(Ok(status)) => rsx!(div {
class: "login_status",
if let (Some(users), Some(max_users)) = (status.users, status.max_users) {
span {"{users}/{max_users} Online"}
} else {
span {"Unknown Online"}
}
span {"-"}
if let Some((maj, min, pat)) = status.version {
span {"Version: {maj}.{min}.{pat}"}
} else {
span {"Unknown Version"}
}
}),
Some(Err(_)) => rsx!(div {
class: "login_status is_error",
span {
"Could not reach proxy server"
}
}),
}
div {
{bottom}
}
}
}
)
// rsx!(
// div {
// class: "{login_box}",
// h1 {
// "Mumble Web"
// }
// input {
// placeholder: "username",
// value: "{username.read()}",
// autofocus: "true",
// oninput: move |evt| username.set(evt.value().clone()),
// }
// input {
// placeholder: "server address",
// value: "{address.read()}",
// autofocus: "true",
// oninput: move |evt| address_input.set(Some(evt.value().clone())),
// }
// {bottom}
// }
// )
}
pub fn app() -> Element {
static STYLE: Asset = asset!("/assets/main.scss");
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
let config = use_resource(|| async move {
match imp::load_config().await {
Ok(config) => config,
Err(_) => ClientConfig::default(),
}
});
imp::request_permissions();
rsx!(
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" }
document::Link{ rel: "stylesheet", href: STYLE }
match *STATE.status.read() {
Connected => rsx!(ServerView { config }),
_ => rsx!(LoginView { config }),
}
)
}
+196
View File
@@ -0,0 +1,196 @@
use crossbeam::atomic::AtomicCell;
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
use df::tract::{DfParams, DfTract, RuntimeParams};
use dioxus::prelude::{asset, manganis, Asset};
use dioxus_asset_resolver::read_asset_bytes;
use std::cell::RefCell;
use std::sync::Arc;
use tracing::{error, info};
use crate::imp;
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
// TODO: make this user configurable.
static DEFAULT_NOISE_FLOOR: f32 = 0.001;
// 200ms hold at 48kHz sample rate
static HOLD_SAMPLES_MAX: usize = 48000 / 5; // 9600 samples = 200ms
/// Indicates the transmission state after processing audio.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransmitState {
/// Audio is above threshold, or below but within hold period - transmit normally
Transmitting,
/// Hold period expired - send this frame as terminator (end_bit = true)
Terminator,
/// Silent and not transmitting - don't send anything
Silent,
}
enum DenoisingModelState {
Nothing,
Downloading(Arc<AtomicCell<Option<DfParams>>>),
Availible(Box<DfTract>),
}
fn with_denoising_model<O>(
spawn: &imp::SpawnHandle,
func: impl FnOnce(&mut DfTract) -> O,
) -> Option<O> {
// Using a thread local is super gross, but DfTract is not Send (so it can never leave the current
// thread) while AudioProcessing itself might change threads whenever.
thread_local! {
static STATE: RefCell<DenoisingModelState> = const { RefCell::new(DenoisingModelState::Nothing) };
}
STATE.with_borrow_mut(|state| match state {
DenoisingModelState::Nothing => {
let cell = Arc::new(AtomicCell::new(None));
let cell_task = cell.clone();
*state = DenoisingModelState::Downloading(cell);
spawn.spawn(async move {
let model_bytes = match read_asset_bytes(&DF_MODEL).await {
Ok(b) => b,
Err(e) => {
error!("could not read denoising model from \"{DF_MODEL}\": {e:?}");
return;
}
};
let params = match DfParams::from_bytes(&model_bytes) {
Ok(p) => p,
Err(e) => {
error!("could not load denoising model parameters: {e:?}");
return;
}
};
cell_task.store(Some(params));
});
None
}
DenoisingModelState::Downloading(cell) => {
if let Some(params) = cell.take() {
let mut tract = match DfTract::new(params, &RuntimeParams::default_with_ch(1)) {
Ok(t) => Box::new(t),
Err(e) => {
error!("could not create denoising engine: {e:?}");
return None;
}
};
info!("instantiated denoising engine");
let out = func(&mut tract);
*state = DenoisingModelState::Availible(tract);
Some(out)
} else {
None
}
}
DenoisingModelState::Availible(tract) => Some(func(tract)),
})
}
pub struct AudioProcessor {
denoise: bool,
spawn: imp::SpawnHandle,
buffer: Vec<f32>,
noise_floor: f32,
/// Whether we were transmitting in the previous frame
was_transmitting: bool,
/// Number of samples we've been below threshold (for hold period)
hold_samples: usize,
}
impl AudioProcessor {
pub fn new_plain() -> Self {
AudioProcessor {
denoise: false,
spawn: imp::SpawnHandle::current(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
hold_samples: 0,
}
}
pub fn new_denoising() -> Self {
AudioProcessor {
denoise: true,
spawn: imp::SpawnHandle::current(),
buffer: Vec::new(),
noise_floor: DEFAULT_NOISE_FLOOR,
was_transmitting: false,
hold_samples: 0,
}
}
}
impl AudioProcessor {
pub fn process(&mut self, audio: &[f32], channels: usize, output: &mut Vec<f32>) -> TransmitState {
let mut include_raw = true;
if self.denoise {
with_denoising_model(&self.spawn, |df| {
include_raw = false;
self.buffer.extend(audio.iter().step_by(channels).copied());
output.reserve(audio.len());
let hop = df.hop_size;
let mut i = 0;
while self.buffer[i..].len() >= hop {
let audio = &self.buffer[i..][..hop];
i += audio.len();
let j = output.len();
output.extend(std::iter::repeat_n(0f32, audio.len()));
let output = &mut output[j..];
df.process(
slice_as_arrayview(audio, &[audio.len()])
.into_shape((1, audio.len()))
.unwrap(),
mut_slice_as_arrayviewmut(output, &[output.len()])
.into_shape((1, output.len()))
.unwrap(),
);
}
self.buffer.splice(..i, []);
});
}
if include_raw {
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
}
}
pub type AudioProcessorSender = Arc<AtomicCell<Option<AudioProcessor>>>;
+18 -87
View File
@@ -1,89 +1,20 @@
use crate::app::Command;
use anyhow::Result;
use dioxus::hooks::{UnboundedReceiver, UnboundedSender};
use futures::io::{AsyncRead, AsyncWrite};
use mumble_protocol::control::{ClientControlCodec, ControlPacket};
use mumble_protocol::Serverbound;
use color_eyre::eyre::{bail, Error};
use dioxus::hooks::UnboundedReceiver;
use mumble_protocol::control::ClientControlCodec;
use std::net::ToSocketAddrs;
use std::{fmt, io, sync::Arc};
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio::task::LocalSet;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::ClientConfig;
use tokio_rustls::rustls::ClientConfig as RlsClientConfig;
use tokio_rustls::rustls::DigitallySignedStruct;
use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{info, instrument};
pub use tokio::task::spawn;
pub use tokio::time::sleep;
pub struct Error(anyhow::Error);
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
impl From<anyhow::Error> for Error {
fn from(value: anyhow::Error) -> Self {
Error(value)
}
}
impl From<io::Error> for Error {
fn from(value: io::Error) -> Self {
Error(value.into())
}
}
impl Error {
pub fn new(text: String) -> Self {
Self(anyhow::Error::msg(text))
}
pub fn log(&self) {
eprintln!("{}", self.0);
}
}
impl std::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
pub struct AudioSystem();
impl AudioSystem {
pub fn new(sender: UnboundedSender<ControlPacket<Serverbound>>) -> Result<Self, Error> {
// dbg!("todo");
Ok(AudioSystem())
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
// dbg!("todo");
Ok(AudioPlayer())
}
}
pub struct AudioPlayer();
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
// dbg!("todo");
}
}
use mumble_web2_common::{ClientConfig, ServerStatus};
#[derive(Debug)]
struct NoCertificateVerification;
@@ -137,12 +68,16 @@ impl ServerCertVerifier for NoCertificateVerification {
}
}
#[instrument]
pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
let config = ClientConfig::builder()
info!("connecting");
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
@@ -157,23 +92,19 @@ pub async fn network_connect(
let server_tcp = TcpStream::connect(addr).await?;
let server_stream = connector
//.connect("127.0.0.1".try_into()?, server_tcp)
.connect(address.try_into().map_err(anyhow::Error::from)?, 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 mut reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let mut writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
super::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<()> {
None
}
pub fn load_username() -> Option<String> {
return None;
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
bail!("status not supported on desktop yet")
}
+82
View File
@@ -0,0 +1,82 @@
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use mumble_web2_common::ClientConfig;
use std::collections::HashMap;
pub use tokio::runtime::Handle as SpawnHandle;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
pub use super::connect::*;
pub use super::native_audio::*;
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,
cert_hash: None,
any_server: true,
})
}
pub fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
+67
View File
@@ -0,0 +1,67 @@
use android_permissions::{PermissionManager, RECORD_AUDIO};
use jni::{objects::JObject, JavaVM};
use mumble_web2_common::ClientConfig;
use std::collections::HashMap;
pub use tokio::runtime::Handle as SpawnHandle;
pub use tokio::task::spawn;
pub use tokio::time::sleep;
pub use super::connect::*;
pub use super::native_audio::*;
pub fn set_default_username(username: &str) -> Option<()> {
None
}
pub fn set_default_server(server: &str) -> Option<()> {
None
}
pub fn load_username() -> Option<String> {
None
}
pub fn load_server_url() -> Option<String> {
None
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
pub fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
#[cfg(feature = "mobile")]
pub fn request_permissions() {
request_recording_permission();
}
#[cfg(target_os = "android")]
pub fn request_recording_permission() {
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();
}
}
+29
View File
@@ -0,0 +1,29 @@
#[cfg(feature = "web")]
mod web;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod connect;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
#[cfg(feature = "desktop")]
mod desktop;
#[cfg(feature = "mobile")]
mod mobile;
#[cfg(feature = "desktop")]
pub use desktop::*;
#[cfg(feature = "mobile")]
pub use mobile::*;
#[cfg(feature = "mobile")]
pub use mobile::request_permissions;
#[cfg(any(feature = "desktop", feature = "web"))]
pub fn request_permissions() {}
#[cfg(all(feature = "web", not(any(feature = "desktop", feature = "mobile"))))]
pub use web::*;
#[cfg(any(feature = "desktop"))]
pub use desktop::*;
+222
View File
@@ -0,0 +1,222 @@
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{eyre, Error};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
use futures::io::{AsyncRead, AsyncWrite};
use std::mem::replace;
use std::sync::Arc;
use std::sync::Mutex;
use tracing::{error, info, warn};
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
pub struct AudioSystem {
output: cpal::Device,
input: cpal::Device,
processors: AudioProcessorSender,
recording_stream: Option<cpal::Stream>,
}
const SAMPLE_RATE: u32 = 48_000;
const PACKET_SAMPLES: u32 = 960;
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 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>, 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())
}
}
}
pub fn create_player(&mut self) -> Result<AudioPlayer, 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(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");
}
}
}
+511
View File
@@ -0,0 +1,511 @@
use crate::app::Command;
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
use color_eyre::eyre::{bail, eyre, Error};
use crossbeam::atomic::AtomicCell;
use dioxus::prelude::*;
use futures::{AsyncRead, AsyncWrite};
use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array;
use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ClientConfig, ServerStatus};
use reqwest::Url;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use tracing::level_filters::LevelFilter;
use tracing::{debug, error, info, instrument};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::js_sys::{Promise, Reflect, Uint8Array};
use web_sys::AudioContextOptions;
use web_sys::AudioData;
use web_sys::AudioDecoder;
use web_sys::AudioDecoderConfig;
use web_sys::AudioDecoderInit;
use web_sys::AudioEncoder;
use web_sys::AudioEncoderConfig;
use web_sys::AudioEncoderInit;
use web_sys::AudioWorkletNode;
use web_sys::EncodedAudioChunk;
use web_sys::EncodedAudioChunkInit;
use web_sys::EncodedAudioChunkType;
use web_sys::MediaStream;
use web_sys::MediaStreamConstraints;
use web_sys::MessageEvent;
use web_sys::WebTransport;
use web_sys::WebTransportBidirectionalStream;
use web_sys::WebTransportOptions;
use web_sys::WorkletOptions;
use web_sys::{console, window};
use web_sys::{AudioContext, AudioDataCopyToOptions};
pub use wasm_bindgen_futures::spawn_local as spawn;
pub trait ImpRead: AsyncRead + Unpin + 'static {}
impl<T: AsyncRead + Unpin + 'static> ImpRead for T {}
pub trait ImpWrite: AsyncWrite + Unpin + 'static {}
impl<T: AsyncWrite + Unpin + 'static> ImpWrite for T {}
pub async fn sleep(d: Duration) {
TimeoutFuture::new(d.as_millis() as u32).await
}
trait ResultExt<T> {
fn ey(self) -> Result<T, Error>;
}
impl<T> ResultExt<T> for Result<T, JsValue> {
fn ey(self) -> Result<T, Error> {
match self {
Ok(x) => Ok(x),
Err(e) => match e.dyn_into::<js_sys::Error>() {
Ok(e) => Err(eyre!("{}: {}", e.name(), e.message())),
Err(e) => Err(eyre!("{:?}", e)),
},
}
}
}
impl<T> ResultExt<T> for Result<T, JsError> {
fn ey(self) -> Result<T, Error> {
self.map_err(|e| JsValue::from(e)).ey()
}
}
pub struct AudioSystem {
webctx: AudioContext,
processors: AudioProcessorSender,
}
async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
// Create worklets to process mic and speaker audio
// Speaker audio processing worklet only required on
// browsers that don't support MediaStreamTrackGenerator
let options = WorkletOptions::new();
Reflect::set(
&options,
&"processorOptions".into(),
&wasm_bindgen::module(),
)
.ey()?;
let module = asset!("assets/rust_audio_worklet.js").to_string();
info!("loading mic worklet from {module:?}");
audio_context
.audio_worklet()
.ey()?
.add_module_with_options(&module, &options)
.ey()?
.into_future()
.await
.ey()?;
Ok(())
}
impl AudioSystem {
pub async fn new() -> Result<Self, Error> {
// Create MediaStreams to playback decoded audio
// The audio context is used to reproduce audio.
let webctx = configure_audio_context();
attach_worklet(&webctx).await?;
let processors = AudioProcessorSender::default();
Ok(AudioSystem { webctx, processors })
}
pub fn set_processor(&self, processor: AudioProcessor) {
self.processors.store(Some(processor))
}
pub fn start_recording(
&mut self,
each: impl FnMut(Vec<u8>, bool) + 'static,
) -> Result<(), Error> {
let audio_context_worklet = self.webctx.clone();
let processors = self.processors.clone();
spawn(async move {
match run_encoder_worklet(&audio_context_worklet, each, processors).await {
Ok(node) => info!("created encoder worklet: {:?}", &node),
Err(err) => error!("could not create encoder worklet: {err}"),
}
});
Ok(())
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let sink_node = AudioWorkletNode::new(&self.webctx, "rust_speaker_worklet").ey()?;
// Connect worklet to destination
sink_node
.connect_with_audio_node(&self.webctx.destination())
.ey()?;
// Create callback functions for AudioDecoder
let decoder_error = Closure::wrap(Box::new(move |e: JsValue| {
error!("error decoding audio {:?}", e);
}) as Box<dyn FnMut(JsValue)>);
let sink_port = sink_node.port().ey()?;
let output = Closure::wrap(Box::new(move |audio_data: AudioData| {
// Extract planar PCM from AudioData into an ArrayBuffer or Float32Array
// Here we assume f32 samples, 1 channel for brevity.
let number_of_frames = audio_data.number_of_frames();
let js_buffer = Float32Array::new_with_length(number_of_frames);
let audio_data_copy_to_options = &AudioDataCopyToOptions::new(0);
audio_data_copy_to_options.set_format(web_sys::AudioSampleFormat::F32);
if let Err(e) = audio_data
.copy_to_with_buffer_source(&js_buffer.buffer(), &audio_data_copy_to_options)
{
error!("could not copy audio data to array {:?}", e);
}
// Post to the worklet; include sampleRate and channel count if needed.
let msg = js_sys::Object::new();
js_sys::Reflect::set(&msg, &"samples".into(), &js_buffer).unwrap();
sink_port.post_message(&msg).unwrap();
audio_data.close();
}) as Box<dyn FnMut(AudioData)>);
let audio_decoder = AudioDecoder::new(&AudioDecoderInit::new(
decoder_error.as_ref().unchecked_ref(),
output.as_ref().unchecked_ref(),
))
.ey()?;
audio_decoder.configure(&AudioDecoderConfig::new("opus", 1, 48000));
info!("created audio decoder");
// This is required to prevent these from being deallocated
decoder_error.forget();
output.forget();
Ok(AudioPlayer(audio_decoder))
}
}
pub struct AudioPlayer(AudioDecoder);
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
let js_audio_payload = Uint8Array::from(payload);
let _ = self.0.decode(
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
&js_audio_payload.into(),
0.0,
EncodedAudioChunkType::Key,
))
.unwrap(),
);
}
}
// Borrowed from
// https://github.com/security-union/videocall-rs/blob/main/videocall-client/src/decode/config.rs#L6
fn configure_audio_context() -> AudioContext {
let audio_context_options = AudioContextOptions::new();
audio_context_options.set_sample_rate(48000 as f32);
let audio_context = AudioContext::new_with_context_options(&audio_context_options).unwrap();
audio_context
}
trait PromiseExt {
fn into_future(self) -> JsFuture;
}
impl PromiseExt for Promise {
fn into_future(self) -> JsFuture {
self.into()
}
}
fn process_audio(frame: &JsValue, processor: &mut AudioProcessor) -> TransmitState {
let Ok(samples) = Reflect::get(&frame, &"data".into()) else {
return TransmitState::Silent;
};
let Ok(samples) = samples.dyn_into::<Float32Array>() else {
return TransmitState::Silent;
};
let input = samples.to_vec();
let mut output = Vec::with_capacity(input.len());
let state = processor.process(&input, 1, &mut output);
if !output.is_empty() {
samples.copy_from(&output);
}
state
}
async fn run_encoder_worklet(
audio_context: &AudioContext,
mut each: impl FnMut(Vec<u8>, bool) + 'static,
processors: AudioProcessorSender,
) -> Result<AudioWorkletNode, Error> {
let constraints = MediaStreamConstraints::new();
constraints.set_audio(&JsValue::TRUE);
let stream = window()
.unwrap()
.navigator()
.media_devices()
.ey()?
.get_user_media_with_constraints(&constraints)
.ey()?
.into_future()
.await
.ey()?
.dyn_into()
.map_err(|e| JsError::new(&format!("not a stream: {e:?}")))
.ey()?;
let source = audio_context.create_media_stream_source(&stream).ey()?;
let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet").ey()?;
let encoder_error: Closure<dyn FnMut(JsValue)> =
Closure::new(|e| error!("error encoding audio {:?}", e));
// Shared state to signal terminator between onmessage and output closures
// The output closure runs asynchronously after encoding completes
let pending_terminator = Arc::new(AtomicCell::new(false));
let pending_terminator_output = pending_terminator.clone();
// This knows what MediaStreamTrackGenerator to use as it closes around it
let output: Closure<dyn FnMut(EncodedAudioChunk)> =
Closure::new(move |audio_data: EncodedAudioChunk| {
let mut array = vec![0u8; audio_data.byte_length() as usize];
audio_data.copy_to_with_u8_slice(&mut array);
// Check if this frame was marked as a terminator
let is_terminator = pending_terminator_output.swap(false);
each(array, is_terminator);
});
let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new(
encoder_error.as_ref().unchecked_ref(),
output.as_ref().unchecked_ref(),
))
.unwrap();
// This is required to prevent these from being deallocated
encoder_error.forget();
output.forget();
let encoder_config = AudioEncoderConfig::new("opus");
encoder_config.set_number_of_channels(1);
encoder_config.set_sample_rate(48000);
encoder_config.set_bitrate(72_000.0);
audio_encoder.configure(&encoder_config);
info!("created audio encoder");
let mut current_processor = AudioProcessor::new_plain();
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
if let Some(new_processor) = processors.take() {
current_processor = new_processor;
}
let frame = event.data();
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()) {
Ok(data) => {
let _ = audio_encoder.encode(&data);
}
Err(err) => {
error!(
"error creating AudioData object {:?} during event {:?}",
err, event,
);
}
}
});
Reflect::set(
&Reflect::get(&worklet_node, &"port".into()).ey()?,
&"onmessage".into(),
onmessage.as_ref(),
)
.ey()?;
onmessage.forget();
source.connect_with_audio_node(&worklet_node).ey()?;
worklet_node
.connect_with_audio_node(&audio_context.destination())
.ey()?;
Ok(worklet_node)
}
#[instrument]
pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
info!("connecting");
let object = web_sys::js_sys::Object::new();
Reflect::set(
&object,
&JsValue::from_str("algorithm"),
&JsValue::from_str("sha-256"),
)
.ey()?;
if let Some(server_hash) = &gui_config.cert_hash {
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?;
}
let array = web_sys::js_sys::Array::new();
array.push(&object);
debug!("created option object: {:?}", &object);
let mut options = WebTransportOptions::new();
options.set_server_certificate_hashes(&array);
debug!("created WebTransportOptions");
console::log_1(&options.clone().into());
let transport = WebTransport::new_with_options(&address, &options).ey()?;
debug!("created WebTransport connection object");
console::log_1(&transport.clone().into());
if let Err(e) = wasm_bindgen_futures::JsFuture::from(transport.ready())
.await
.ey()
{
bail!("could not connect to transport: {e}");
}
info!("transport is ready");
let stream: WebTransportBidirectionalStream =
wasm_bindgen_futures::JsFuture::from(transport.create_bidirectional_stream())
.await
.ey()?
.into();
let wasm_stream_readable = wasm_streams::ReadableStream::from_raw(stream.readable().into());
let wasm_stream_writable = wasm_streams::WritableStream::from_raw(stream.writable().into());
let read_codec = ClientControlCodec::new();
let write_codec = ClientControlCodec::new();
let reader =
asynchronous_codec::FramedRead::new(wasm_stream_readable.into_async_read(), read_codec);
let writer =
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
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> {
let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
let location = window.location();
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
}
pub async fn load_config() -> color_eyre::Result<ClientConfig> {
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
Some(url) => Url::parse(url)?,
None => absolute_url("config")?,
};
info!("loading config from {}", config_url);
let config = reqwest::get(config_url)
.await?
.json::<ClientConfig>()
.await?;
Ok(config)
}
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
Ok(client
.get(absolute_url("status")?)
.send()
.await?
.json::<ServerStatus>()
.await?)
}
pub fn init_logging() {
// copied from tracing_web example usage
use tracing_subscriber::fmt::format::Pretty;
use tracing_subscriber::prelude::*;
use tracing_web::{performance_layer, MakeWebConsoleWriter};
let fmt_layer = tracing_subscriber::fmt::layer()
.with_ansi(false) // Only partially supported across browsers
.without_time() // std::time is not available in browsers
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
.with_filter(LevelFilter::DEBUG);
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
tracing_subscriber::registry()
.with(fmt_layer)
.with(perf_layer)
.init();
info!("logging initiated");
}
pub struct SpawnHandle;
impl SpawnHandle {
pub fn current() -> Self {
SpawnHandle
}
pub fn spawn<F>(&self, future: F)
where
F: Future<Output = ()> + 'static,
{
spawn(future);
}
}
+101 -32
View File
@@ -4,6 +4,7 @@ use app::ConnectionState;
use app::STATE;
use asynchronous_codec::FramedRead;
use asynchronous_codec::FramedWrite;
use color_eyre::eyre::{bail, Error};
use dioxus::prelude::*;
use futures::select;
use futures::FutureExt as _;
@@ -11,44 +12,43 @@ use futures::SinkExt as _;
use futures::StreamExt as _;
use futures_channel::mpsc::UnboundedSender;
pub use imp::spawn;
pub use imp::Error;
use msghtml::process_message_html;
use mumble_protocol::control::msgs;
use mumble_protocol::control::ControlCodec;
use mumble_protocol::control::ControlPacket;
use mumble_protocol::voice::VoicePacket;
use mumble_protocol::voice::VoicePacketPayload;
use mumble_protocol::Clientbound;
use mumble_protocol::Serverbound;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::time::Duration;
use tracing::error;
use tracing::info;
use crate::effects::AudioProcessor;
use crate::imp::AudioSystem;
pub mod app;
#[cfg(feature = "web")]
#[path = "imp/web.rs"]
mod effects;
pub mod imp;
#[cfg(feature = "desktop")]
#[path = "imp/desktop.rs"]
pub mod imp;
#[macro_export]
macro_rules! bail {
($($x:tt)*) => {
return Err(Error::new(format!($($x)*)))
};
}
mod msghtml;
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
loop {
let Some(Command::Connect { address, username }) = event_rx.next().await else {
panic!("Did not receive connect command")
let Some(Command::Connect {
address,
username,
config,
}) = event_rx.next().await
else {
panic!("did not receive connect command")
};
*STATE.server.write() = Default::default();
*STATE.status.write() = ConnectionState::Connecting;
if let Err(error) = imp::network_connect(address, username, &mut event_rx).await {
error.log();
if let Err(error) = imp::network_connect(address, username, &mut event_rx, &config).await {
error!("could not connect {:?}", error);
*STATE.status.write() = ConnectionState::Failed(error.to_string());
} else {
*STATE.status.write() = ConnectionState::Disconnected;
@@ -66,10 +66,10 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
spawn(async move {
while let Some(msg) = writer_recv_chan.next().await {
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
eprintln!("sending {:#?}", msg);
info!("sending packet {:#?}", msg);
}
if let Err(e) = writer.send(msg).await {
eprintln!("ERROR: {}", e);
error!("error sending packet {:?}", e);
break;
}
}
@@ -81,8 +81,7 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
Some(Err(err)) => bail!("bad version packet: {err:?}"),
None => bail!("no version was recieved"),
};
println!("Got version packet");
println!("{:#?}", version);
info!("got version packet {:#?}", version);
// Send version packet
let mut msg = msgs::Version::new();
@@ -111,7 +110,23 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
});
}
let mut audio = imp::AudioSystem::new(send_chan.clone())?;
let mut audio = imp::AudioSystem::new().await?;
{
let send_chan = send_chan.clone();
let mut sequence_num = 0;
audio.start_recording(move |opus_frame, is_terminator| {
let _ =
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
_dst: std::marker::PhantomData,
target: 0,
session_id: (),
seq_num: sequence_num,
payload: VoicePacketPayload::Opus(opus_frame.into(), is_terminator),
position_info: None,
})));
sequence_num = sequence_num.wrapping_add(2);
});
}
// Create map of session_id -> AudioDecoder
let mut decoder_map = HashMap::new();
@@ -126,28 +141,30 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
match packet {
Some(Ok(msg)) => {
if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) {
println!("receiving {:#?}", msg);
info!("receiving packet {:#?}", msg);
}
let res = accept_packet(msg, &mut audio, &mut decoder_map);
if let Err(err) = res {
err.log();
error!("error accepting packet {:?}", err)
}
},
Some(Err(err)) => Error::from(err).log(),
Some(Err(err)) => {
error!("error receiving packet {:?}", err)
},
None => break,
}
}
command = command_future => {
command_future = event_rx.next();
if let Some(command) = &command {
println!("commanding {:#?}", command);
info!("issuing command {:#?}", command);
}
match command {
Some(Command::Disconnect) => break,
Some(command) => {
let res = accept_command(command, &mut send_chan);
let res = accept_command(command, &mut send_chan, &mut audio);
if let Err(err) = res {
err.log();
info!("error accepting command {:?}", err)
}
}
None => continue,
@@ -163,6 +180,7 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
fn accept_command(
command: Command,
send_chan: &mut UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
audio: &mut AudioSystem,
) -> Result<(), Error> {
use Command::*;
let Some(session) = STATE.server.read().session else {
@@ -204,6 +222,47 @@ fn accept_command(
u.set_channel_id(channels);
let _ = send_chan.unbounded_send(u.into());
}
SendFile {
ref bytes,
name,
mime,
channels,
} => {
use base64::{display::Base64Display, prelude::BASE64_STANDARD};
let html = match mime {
Some(mime) if mime.type_() == "image" => format!(
"<img src=\"data:{};base64,{}\" />",
mime,
Base64Display::new(bytes, &BASE64_STANDARD)
),
Some(mime) => format!(
"<a href=\"data:{};base64,{}\" download>{name}</a>",
mime,
Base64Display::new(bytes, &BASE64_STANDARD)
),
None => format!(
"<a href=\"data:application/octet-stream;base64,{}\" download>{name}</a>",
Base64Display::new(bytes, &BASE64_STANDARD)
),
};
{
let mut server = STATE.server.write();
let Some(me) = server.session else {
bail!("not signed in with a session id")
};
server.chat.push(Chat {
raw: "".to_string(),
dangerous_html: html.clone(),
sender: Some(me),
})
}
let mut u = msgs::TextMessage::new();
u.set_message(html);
u.set_channel_id(channels);
let _ = send_chan.unbounded_send(u.into());
}
SetMute { mute } => {
let mut u = msgs::UserState::new();
u.set_session(session);
@@ -223,6 +282,13 @@ fn accept_command(
let _ = send_chan.unbounded_send(u.into());
}
Connect { .. } | Disconnect => (),
UpdateMicEffects { denoise } => {
if denoise {
audio.set_processor(AudioProcessor::new_denoising());
} else {
audio.set_processor(AudioProcessor::new_plain());
}
}
}
Ok(())
@@ -338,6 +404,9 @@ fn accept_packet(
if u.has_deaf() {
state.deaf = u.get_deaf();
}
if u.has_suppress() {
state.suppress = u.get_suppress();
}
if u.has_self_mute() {
state.self_mute = u.get_self_mute();
}
@@ -364,7 +433,7 @@ fn accept_packet(
} else {
None
},
dangerous_html: html_purifier::purifier(&text, Default::default()),
dangerous_html: process_message_html(&text),
raw: text,
});
}
@@ -376,7 +445,7 @@ fn accept_packet(
let text = u.get_welcome_text().to_string();
server.chat.push(Chat {
sender: None,
dangerous_html: html_purifier::purifier(&text, Default::default()),
dangerous_html: process_message_html(&text),
raw: text,
});
}
+6
View File
@@ -0,0 +1,6 @@
use mumble_web2_gui::{app, imp};
pub fn main() {
imp::init_logging();
dioxus::launch(app::app);
}
+110
View File
@@ -0,0 +1,110 @@
// This is a fork of https://github.com/mehmetcansahin/html-purifier
use lol_html::html_content::{Comment, Element};
use lol_html::{comments, element, rewrite_str, RewriteStrSettings};
pub struct AllowedElement {
pub name: &'static str,
pub attributes: &'static [&'static str],
}
const ALLOWED: &'static [AllowedElement] = &[
AllowedElement {
name: "div",
attributes: &[],
},
AllowedElement {
name: "b",
attributes: &[],
},
AllowedElement {
name: "strong",
attributes: &[],
},
AllowedElement {
name: "i",
attributes: &[],
},
AllowedElement {
name: "em",
attributes: &[],
},
AllowedElement {
name: "u",
attributes: &[],
},
AllowedElement {
name: "a",
attributes: &["href", "title"],
},
AllowedElement {
name: "ul",
attributes: &[],
},
AllowedElement {
name: "ol",
attributes: &[],
},
AllowedElement {
name: "li",
attributes: &[],
},
AllowedElement {
name: "p",
attributes: &["style"],
},
AllowedElement {
name: "br",
attributes: &[],
},
AllowedElement {
name: "span",
attributes: &["style"],
},
AllowedElement {
name: "img",
attributes: &["width", "height", "alt", "src"],
},
];
pub fn process_message_html(input: &str) -> String {
let element_handler = |el: &mut Element| {
let find = ALLOWED.iter().find(|e| e.name.eq(&el.tag_name()));
match find {
Some(find) => {
let remove_attributes = el
.attributes()
.iter()
.filter(|e| find.attributes.iter().any(|a| a.eq(&e.name())) == false)
.map(|m| m.name())
.collect::<Vec<String>>();
for attr in remove_attributes {
el.remove_attribute(&attr);
}
}
None => {
el.remove_and_keep_content();
}
}
if el.tag_name() == "a" {
el.set_attribute("target", "_blank");
}
Ok(())
};
let comment_handler = |c: &mut Comment| {
c.remove();
Ok(())
};
let output = rewrite_str(
input,
RewriteStrSettings {
element_content_handlers: vec![
element!("*", element_handler),
comments!("*", comment_handler),
],
..RewriteStrSettings::default()
},
)
.unwrap();
return output;
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "mumble-web2-proxy"
version = "0.1.0"
edition = "2021"
[dependencies]
color-eyre = "^0.6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tokio-rustls = "0.26"
toml = "0.8"
tracing = { version = "^0.1.40", features = ["async-await"] }
tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] }
mumble-web2-common = { workspace = true }
salvo = { version = "^0.84.2", features = [
"quinn",
"eyre",
"rustls",
"serve-static",
"logging",
"craft",
"cors",
] }
once_cell = "^1.20"
rustls = { version = "^0.23", features = ["aws_lc_rs"] }
rcgen = "^0.13.2"
hmac-sha256 = "^1.1.8"
time = "0.3"
url = { version = "2", features = ["serde"] }
rand = "0.9.2"
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
cargo build --release
rm -rf mumble-web2
git clone https://git.ohea.xyz/mumble/mumble-web2
cd mumble-web2
echo "[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]" >server_hash.txt
dx build --release
cd ..
rm -rf bundle
mkdir bundle
cp target/release/mumble-webtransport-proxy bundle
cp -r mumble-web2/dist bundle/gui
+429
View File
@@ -0,0 +1,429 @@
use color_eyre::eyre::{anyhow, bail, Context, Result};
use mumble_web2_common::{ClientConfig, ServerStatus};
use rand::Rng;
use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::cors::{AllowOrigin, Cors};
use salvo::logging::Logger;
use salvo::prelude::*;
use salvo::proto::quic::BidiStream;
use serde::{Deserialize, Serialize};
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::pin;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::{ClientConfig as RlsClientConfig, DigitallySignedStruct};
use tokio_rustls::{rustls, TlsConnector};
use tracing::info;
use tracing::info_span;
use tracing::Instrument;
use tracing::{error, instrument};
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::EnvFilter;
use url::Url;
mod ping;
fn default_cert_alt_names() -> Vec<String> {
vec!["localhost".into()]
}
#[derive(Debug, Deserialize, Serialize)]
struct Config {
proxy_url: Option<Url>,
https_listen_address: SocketAddr,
http_listen_address: Option<SocketAddr>,
cert_path: Option<PathBuf>,
key_path: Option<PathBuf>,
#[serde(default = "default_cert_alt_names")]
cert_alt_names: Vec<String>,
mumble_server_url: String,
mumble_server_address: Option<SocketAddr>,
gui_path: Option<PathBuf>,
}
fn init_config() -> Result<Config> {
let mut config: Config = toml::from_str(
&std::fs::read_to_string("./config.toml")
.context("reading config.toml (try making a copy of config.toml.example)")?,
)?;
let mumble_server_addr = config
.mumble_server_url
.to_socket_addrs()
.context(format!(
"parsing mumble_server_url={}",
config.mumble_server_url
))?
.next()
.ok_or(anyhow!(
"no socket addrs in mumble_server_url={}",
config.mumble_server_url
))?;
config.mumble_server_address = Some(mumble_server_addr);
Ok(config)
}
#[tokio::main]
async fn main() -> Result<()> {
init_logging();
let server_config = Arc::new(init_config()?);
info!("config:\n{}", toml::to_string_pretty(&*server_config)?);
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?;
let mut client_config = ClientConfig {
proxy_url: match &server_config.proxy_url {
Some(url) => Some(url.to_string()),
None => None,
},
cert_hash: None,
any_server: false,
};
let (cert, key) = match (&server_config.cert_path, &server_config.key_path) {
(None, None) => {
info!("generating self-signed cert");
// FIXME: redo every <14 days
let mut dname = rcgen::DistinguishedName::new();
dname.push(rcgen::DnType::CommonName, "mumble-web self-signed");
let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?;
let mut cert_params =
rcgen::CertificateParams::new(server_config.cert_alt_names.clone())?;
cert_params.distinguished_name = dname;
cert_params.not_before = time::OffsetDateTime::now_utc();
cert_params.not_after = cert_params.not_before + time::Duration::days(12);
let cert = cert_params.self_signed(&key_pair)?;
let hash = hmac_sha256::Hash::hash(cert.der().as_ref());
client_config.cert_hash = Some(hash.into());
(cert.pem().into(), key_pair.serialize_pem().into())
}
(Some(cert_path), Some(key_path)) => {
// Read server certs
let cert = fs::read(cert_path)
.await
.context(format!("reading cert {}", cert_path.display()))?;
let key = fs::read(key_path)
.await
.context(format!("reading key {}", key_path.display()))?;
(cert, key)
}
_ => {
bail!("please supply both cert_path and key_path (or neither to generate a self-signed cert)")
}
};
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
info!(
"client config:\n{}",
toml::to_string_pretty(&client_config)?
);
let config_craft = ConfigCraft {
server_config: server_config.clone(),
client_config,
};
let status_craft = StatusCraft {
mumble_server_address: server_config.mumble_server_address.unwrap().clone(),
};
// Server routing
let mut router = Router::new()
.push(Router::with_path("/proxy").goal(config_craft.connect_proxy()))
.push(Router::with_path("/config").get(config_craft.get_config()))
.push(Router::with_path("/status").get(status_craft.get_status()))
.hoop(Logger::new());
if let Some(gui_path) = server_config.gui_path.clone() {
router =
router.push(Router::with_path("/").get(StaticFile::new(gui_path.join("index.html"))));
router = router.push(Router::with_path("/<*+rest>").get(StaticDir::new(gui_path)));
}
let cors = Cors::new().allow_origin(AllowOrigin::any()).into_handler();
let service = Service::new(router).hoop(cors);
// Create http listeners
let http_listener = server_config.http_listen_address.map(TcpListener::new);
let https_listener =
TcpListener::new(server_config.https_listen_address).rustls(rustls_config.clone());
let http3_listener = QuinnListener::new(rustls_config, server_config.https_listen_address);
// Start server
match (http_listener, https_listener, http3_listener) {
(Some(a), b, c) => {
let accepter = a.join(b).join(c).bind().await;
Server::new(accepter).serve(service).await;
}
(None, b, c) => {
let accepter = b.join(c).bind().await;
Server::new(accepter).serve(service).await;
}
}
Ok(())
}
#[derive(Clone)]
pub struct StatusCraft {
mumble_server_address: SocketAddr,
}
#[craft]
impl StatusCraft {
#[craft(handler)]
async fn get_status(&self) -> Json<ServerStatus> {
let mut server_status = ServerStatus::default();
let ping_packet = ping::PingPacket {
id: rand::rng().random(),
};
let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => {
error!("Could not bind udp socket: {}", e);
return Json(server_status);
}
};
match sock.connect(self.mumble_server_address).await {
Ok(_) => {}
Err(e) => {
error!("Could not send ping packet: {}", e);
return Json(server_status);
}
}
match sock.send(&<[u8; 12]>::from(ping_packet)).await {
Ok(_) => {}
Err(e) => {
error!("Could not send ping packet");
return Json(server_status);
}
}
let mut pong_buf: [u8; 24] = [0; 24];
match tokio::time::timeout(
tokio::time::Duration::from_secs(1),
sock.recv(&mut pong_buf),
)
.await
{
Ok(_) => {}
Err(e) => {
error!("Could not send ping packet");
return Json(server_status);
}
}
let pong_packet = match ping::PongPacket::try_from(pong_buf.as_slice()) {
Ok(p) => p,
Err(e) => {
error!("Could not parse pong packet: {:?}", e);
return Json(server_status);
}
};
server_status.success = true;
server_status.version = Some((
pong_packet.version & 0xFF,
(pong_packet.version >> 8) & 0xFF,
(pong_packet.version >> 16) & 0xFF,
));
server_status.users = Some(pong_packet.users);
server_status.max_users = Some(pong_packet.max_users);
server_status.bandwidth = Some(pong_packet.bandwidth);
Json(server_status)
}
}
#[derive(Clone)]
pub struct ConfigCraft {
server_config: Arc<Config>,
client_config: ClientConfig,
}
#[craft]
impl ConfigCraft {
#[craft(handler)]
async fn get_config(&self) -> Json<ClientConfig> {
Json(self.client_config.clone())
}
#[craft(handler)]
async fn connect_proxy(&self, req: &mut Request, res: &mut Response) {
info!("received proxy request");
let mumble_server_address = self.server_config.mumble_server_address.unwrap();
let wt = match req.web_transport_mut().await {
Ok(wt) => wt,
Err(err) => {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!("error with webtransport: {err:?}"));
return;
}
};
info!("got webtransport for connection");
use salvo::webtransport::server::AcceptedBi;
let (id, bi) = match wt.accept_bi().await {
Ok(Some(AcceptedBi::BidiStream(id, bi))) => (id, bi),
Ok(Some(AcceptedBi::Request(req, _))) => {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!(
"expected webtransport stream but got request {req:?}"
));
return;
}
Ok(None) => {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!("no bidirectional connection requested"));
return;
}
Err(err) => {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
res.render(format!("error with bidirectional connection: {err:?}"));
return;
}
};
let (outgoing, incoming) = bi.split();
let res = tokio::spawn(async move {
if let Err(error) = connect_proxy_impl(mumble_server_address, incoming, outgoing).await
{
error!("error connecting proxy {error:?}")
}
})
.await;
if let Err(err) = res {
error!("crash in connected proxy {err:?}");
}
}
}
#[instrument(skip(incoming, outgoing))]
async fn connect_proxy_impl(
mumble_server_address: SocketAddr,
incoming: impl AsyncRead + Send + Sync + 'static,
outgoing: impl AsyncWrite + Send + Sync + 'static,
) -> Result<()> {
info!("connecting to Mumble server...");
let config = RlsClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let server_tcp = TcpStream::connect(mumble_server_address).await?;
let server_stream = connector
.connect("example.com".try_into()?, server_tcp)
.await?;
let (read_server, write_server) = tokio::io::split(server_stream);
info!("connected to Mumble server");
// Handle transmitting data between the WebTransport client and Mumble TCP Server
// When one direction completes/fails, the other is dropped and its streams are closed
tokio::select! {
res = pass_bytes_loop(incoming, write_server)
.instrument(info_span!("Handler", "Client to server")) => res?,
res = pass_bytes_loop(read_server, outgoing)
.instrument(info_span!("Handler", "Server to client")) => res?,
};
Ok(())
}
#[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,
]
}
}
async fn pass_bytes_loop(
client_stream: impl AsyncRead + Sync + Send + 'static,
server_stream: impl AsyncWrite + Send + Sync + 'static,
) -> Result<()> {
let mut buffer = vec![0; 65536].into_boxed_slice();
pin!(client_stream);
pin!(server_stream);
loop {
let bytes_read = client_stream.read(&mut buffer).await?;
if bytes_read == 0 {
break Ok(());
}
server_stream.write_all(&buffer[..bytes_read]).await?;
server_stream.flush().await?;
}
}
fn init_logging() {
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::DEBUG.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
+141
View File
@@ -0,0 +1,141 @@
// This code was taken from mumble-protocol-2x (https://github.com/dblsaiko/rust-mumble-protocol)
// and originally from mumble-protocol (https://github.com/Johni0702/rust-mumble-protocol)
// These projects are licensed under MIT and Apache 2.0.
//! Ping messages and codec
//!
//! A Mumble client can send periodic UDP [PingPacket]s to servers
//! in order to query their current state and measure latency.
//! A server will usually respond with a corresponding [PongPacket] containing
//! the requested details.
//!
//! Both packets are of fixed size and can be converted to/from `u8` arrays/slices via
//! the respective `From`/`TryFrom` impls.
/// A ping packet sent to the server.
#[derive(Clone, Debug, PartialEq)]
pub struct PingPacket {
/// Opaque, client-generated id.
///
/// Will be returned by the server unmodified and can be used to correlate
/// pong replies to ping requests to e.g. calculate latency.
pub id: u64,
}
/// A pong packet sent to the client in reply to a previously received [PingPacket].
#[derive(Clone, Debug, PartialEq)]
pub struct PongPacket {
/// Opaque, client-generated id.
///
/// Should match the value in the corresponding [PingPacket].
pub id: u64,
/// Server version. E.g. `0x010300` for `1.3.0`.
pub version: u32,
/// Current amount of users connected to the server.
pub users: u32,
/// Configured limit on the amount of users which can be connected to the server.
pub max_users: u32,
/// Maximum bandwidth for server-bound speech per client in bits per second
pub bandwidth: u32,
}
/// Error during parsing of a [PingPacket].
#[derive(Clone, Debug, PartialEq)]
pub enum ParsePingError {
/// Ping packets must always be 12 bytes in size.
InvalidSize,
/// Ping packets must have an all zero header of 4 bytes.
InvalidHeader,
}
impl TryFrom<&[u8]> for PingPacket {
type Error = ParsePingError;
fn try_from(buf: &[u8]) -> Result<Self, Self::Error> {
match <[u8; 12]>::try_from(buf) {
Ok(array) => {
if array[0..4] != [0, 0, 0, 0] {
Err(ParsePingError::InvalidHeader)
} else {
Ok(Self {
id: u64::from_be_bytes(array[4..12].try_into().unwrap()),
})
}
}
Err(_) => Err(ParsePingError::InvalidSize),
}
}
}
impl From<PingPacket> for [u8; 12] {
fn from(packet: PingPacket) -> Self {
let id = packet.id.to_be_bytes();
// Is there no nicer way to do this?
[
0, 0, 0, 0, id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7],
]
}
}
/// Error during parsing of a [PongPacket].
#[derive(Clone, Debug, PartialEq)]
pub enum ParsePongError {
/// Pong packets must always be 24 bytes in size.
InvalidSize,
}
impl TryFrom<&[u8]> for PongPacket {
type Error = ParsePongError;
fn try_from(buf: &[u8]) -> Result<Self, Self::Error> {
match <[u8; 24]>::try_from(buf) {
Ok(array) => Ok(Self {
version: u32::from_be_bytes(array[0..4].try_into().unwrap()),
id: u64::from_be_bytes(array[4..12].try_into().unwrap()),
users: u32::from_be_bytes(array[12..16].try_into().unwrap()),
max_users: u32::from_be_bytes(array[16..20].try_into().unwrap()),
bandwidth: u32::from_be_bytes(array[20..24].try_into().unwrap()),
}),
Err(_) => Err(ParsePongError::InvalidSize),
}
}
}
impl From<PongPacket> for [u8; 24] {
fn from(packet: PongPacket) -> Self {
let version = packet.version.to_be_bytes();
let id = packet.id.to_be_bytes();
let users = packet.users.to_be_bytes();
let max_users = packet.max_users.to_be_bytes();
let bandwidth = packet.bandwidth.to_be_bytes();
// Is there no nicer way to do this?
[
version[0],
version[1],
version[2],
version[3],
id[0],
id[1],
id[2],
id[3],
id[4],
id[5],
id[6],
id[7],
users[0],
users[1],
users[2],
users[3],
max_users[0],
max_users[1],
max_users[2],
max_users[3],
bandwidth[0],
bandwidth[1],
bandwidth[2],
bandwidth[3],
]
}
}
-632
View File
@@ -1,632 +0,0 @@
#![allow(non_snake_case)]
use dioxus::prelude::*;
use manganis::mg;
use ordermap::OrderSet;
use sir::{css, global_css};
use std::collections::HashMap;
use crate::imp;
pub type ChannelId = u32;
pub type UserId = u32;
pub enum ConnectionState {
Disconnected,
Connecting,
Connected,
Failed(String),
}
#[derive(Debug)]
pub enum Command {
Connect {
address: String,
username: String,
},
SendChat {
markdown: String,
channels: Vec<ChannelId>,
},
SetMute {
mute: bool,
},
SetDeaf {
deaf: bool,
},
EnterChannel {
channel: ChannelId,
user: UserId,
},
Disconnect,
}
use Command::*;
use ConnectionState::*;
#[derive(Default)]
pub struct ChannelState {
pub name: String,
pub children: OrderSet<ChannelId>,
pub users: OrderSet<UserId>,
pub parent: Option<ChannelId>,
}
#[derive(Default)]
pub struct UserState {
pub name: String,
pub channel: ChannelId,
pub deaf: bool,
pub mute: bool,
pub self_deaf: bool,
pub self_mute: bool,
}
impl UserState {
pub fn icon(&self) -> UserIcon {
match (self.mute || self.self_mute, self.deaf || self.self_deaf) {
(false, false) => UserIcon::Normal,
(true, false) => UserIcon::Muted,
(_, true) => UserIcon::Deafened,
}
}
}
pub struct Chat {
pub raw: String,
pub dangerous_html: String,
pub sender: Option<UserId>,
}
#[derive(Default)]
pub struct ServerState {
pub channels: HashMap<ChannelId, ChannelState>,
pub users: HashMap<UserId, UserState>,
pub chat: Vec<Chat>,
pub session: Option<UserId>,
}
impl ServerState {
pub fn this_user(&self) -> Option<&UserState> {
self.users.get(&self.session?)
}
}
pub struct State {
pub status: GlobalSignal<ConnectionState>,
pub server: GlobalSignal<ServerState>,
}
pub static STATE: State = State {
status: Signal::global(|| Disconnected),
server: Signal::global(|| Default::default()),
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum UserIcon {
Normal,
Muted,
Deafened,
None,
}
impl UserIcon {
pub fn url(self) -> Option<&'static str> {
// speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/
// mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/
use UserIcon::*;
Some(match self {
Normal => "/mic-svgrepo-com.svg",
Muted => "/mic-off-svgrepo-com.svg",
Deafened => "/speaker-muted-svgrepo-com.svg",
None => return Option::None,
})
}
}
#[component]
pub fn UserPill(name: String, icon: UserIcon) -> Element {
let pill = css!(
"
border-radius: 100px;
padding: 4px 8px;
width: fit-content;
img {
height: 1em;
vertical-align: text-bottom;
}
"
);
let color = match icon {
UserIcon::Normal => "var(--accent-a)",
UserIcon::Muted => "var(--accent-b)",
UserIcon::Deafened => "var(--accent-c)",
UserIcon::None => "var(--accent-a)",
};
rsx!(
div {
class: "{pill}",
style: "background-color: {color}",
{ icon.url().map(|url| rsx!(img { src: url })) }
"\u{00A0}{name}\u{00A0}"
}
)
}
#[component]
pub fn User(id: UserId) -> Element {
let server = STATE.server.read();
let state = server.users.get(&id)?;
rsx!(UserPill {
name: state.name.clone(),
icon: state.icon(),
})
}
#[component]
pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let user = server.session?;
let state = server.channels.get(&id)?;
let channel_details = css!(
"
flex: 0 0 100%;
summary {
cursor: pointer;
}
summary:focus-visible {
outline: none;
}
"
);
let channel_children = css!(
"
border-left: solid var(--line-color) var(--line-width);
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-left: 5px;
padding-left: 11px;
padding-top: 4px; "
);
rsx!(
details {
class: "{channel_details}",
open: true,
summary {
span {
role: "button",
prevent_default: "onclick",
ondoubleclick: move |evt| {
evt.stop_propagation();
net.send(EnterChannel { channel: id, user })
},
"{state.name}"
}
}
if state.users.len() + state.children.len() > 0 {
div {
class: "{channel_children}",
for id in state.users.iter() {
User { id: *id }
}
for child in state.children.iter() {
Channel { id: *child }
}
}
}
}
)
}
#[component]
pub fn ChatView() -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let mut draft = use_signal(|| "".to_string());
let chat_history = css!(
"
overflow-y: auto;
flex: 1 0 0;
"
);
let chat_message = css!(
"
display: flex;
flex-direction: row;
margin: 16px;
gap: 8px;
align-items: center;
"
);
let chat_box = css!(
"
display: flex;
flex-direction: row;
padding: 16px;
gap: 8px;
border-top: solid var(--line-color) var(--line-width);
input {
flex-grow: 1;
padding: 8px;
}
"
);
let mut do_send = move || {
if let Some(user) = STATE.server.read().this_user() {
net.send(SendChat {
markdown: draft.write().split_off(0),
channels: vec![user.channel],
});
}
};
rsx!(
div {
class: "{chat_history}",
for chat in server.chat.iter() {
div {
class: "{chat_message}",
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
UserPill {
name: sender.name.clone(),
icon: UserIcon::None,
}
}
span {
dangerous_inner_html: "{chat.dangerous_html}",
}
}
}
}
div {
class: "{chat_box}",
input {
placeholder: "say something",
value: "{draft.read()}",
oninput: move |evt| draft.set(evt.value().clone()),
onkeypress: move |evt: Event<KeyboardData>| {
if evt.code() == Code::Enter && evt.modifiers().is_empty() {
do_send();
}
}
}
button {
onclick: move |_| do_send(),
"Send"
}
}
)
}
#[component]
pub fn ServerView() -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let &UserState {
deaf,
self_deaf,
mute,
self_mute,
..
} = server.this_user()?;
let grid = css!(
r#"
display: grid;
height: 100%;
background-color: var(--bg-color);
grid-template-rows: auto 1fr;
grid-template-columns: 1fr 1fr;
grid-template-areas:
"bar bar"
"tree chat";
@media screen and (max-width: 720px) {
grid-template-rows: auto 1fr 1fr;
grid-template-columns: 1fr;
grid-template-areas:
"bar"
"tree"
"chat";
}
"#
);
let channel_box = css!(
"
padding: 16px;
overflow: auto;
grid-area: tree;
"
);
let chat_box = css!(
"
display: flex;
flex-direction: column;
grid-area: chat;
border-left: solid var(--line-color) var(--line-width);
@media screen and (max-width: 720px) {
border-left:unset;
border-top: solid var(--line-color) var(--line-width);
}
"
);
let top_bar = css!(
"
padding: 16px;
grid-area: bar;
background-color: var(--login-bg-color);
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
button {
padding: 8px;
img {
height: 1em;
vertical-align: text-bottom;
}
}
"
);
rsx!(
div {
class: "{grid}",
div {
class: "{top_bar}",
button {
onclick: move |_| net.send(Disconnect),
"Disconnect"
}
button {
role: "switch",
aria_checked: mute || self_mute,
disabled: mute,
onclick: move |_| net.send(SetMute { mute: !self_mute }),
match mute || self_mute {
true => rsx!(img { src: "/mic-off-svgrepo-com.svg" }),
false => rsx!(img { src: "/mic-svgrepo-com.svg" }),
}
"\u{00A0}Mute"
}
button {
role: "switch",
aria_checked: deaf || self_deaf,
disabled: deaf,
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
match deaf || self_deaf {
true => rsx!(img { src: "/speaker-muted-svgrepo-com.svg" }),
false => rsx!(img { src: "/speaker-medium-svgrepo-com.svg" }),
}
"\u{00A0}Deafen"
}
}
div {
class: "{channel_box}",
for (id, state) in server.channels.iter() {
if state.parent.is_none() {
Channel { id: *id }
}
}
}
div {
class: "{chat_box}",
ChatView {}
}
}
)
}
#[component]
pub fn LoginView() -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let default_address = option_env!("MUMBLEWEB2_WEBTRANSPORT_SERVER_ADDRESS").unwrap_or("");
let mut address = use_signal(|| default_address.to_string());
let previous_username = imp::load_username();
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
let error = css!(
"
background-color: white;
border-radius: 4px;
overflow: auto;
padding: 4px;
color: red;
pre {
color: black;
}
"
);
let login_box = css!(
"
max-width: 50vw;
align-self: center;
padding: 32px;
border-radius: 16px;
background-color: var(--login-bg-color);
display: flex;
flex-direction: column;
gap: 16px;
input,button {
padding: 8px;
}
h1 {
margin: 0;
color: #b3c6b4;
}
"
);
let bttn = css!(
"
font-weight: bold;
font-size: large;
"
);
let do_connect = move |_| {
//let _ = set_default_username(&username.read());
let _ = imp::set_default_username(&username.read());
net.send(Connect {
address: address.read().clone(),
username: username.read().clone(),
})
};
let status = &STATE.status;
let bottom = match &*status.read() {
Disconnected => rsx! {
button {
class: "{bttn}",
onclick: do_connect.clone(),
"Connect"
}
},
Connecting => rsx! {
div {
class: "{bttn}",
"Connecting..."
}
},
Failed(msg) => rsx!(
button {
class: "{bttn}",
onclick: do_connect.clone(),
"Reconnect"
}
div {
class: "{error}",
"Failed to connect:"
pre {
"{msg}"
}
}
),
Connected => unreachable!(),
};
rsx!(
div {
class: "{login_box}",
h1 {
"Mumble Web"
}
input {
placeholder: "username",
value: "{username.read()}",
autofocus: "true",
oninput: move |evt| username.set(evt.value().clone()),
}
input {
placeholder: "server address",
value: "{address.read()}",
autofocus: "true",
oninput: move |evt| address.set(evt.value().clone()),
}
{bottom}
}
)
}
pub fn app() -> Element {
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
global_css!(
"
:root {
--txt-color: white;
--bg-color: #372f3a;
--login-bg-color: #5d7680;
--primary-btn-color: #7bad9f;
--accent-a: #8eb29a;
--accent-b: #6a9395;
--accent-c: #464459;
--line-width: 2px;
--line-color: white;
}
body {
margin: 0;
}
#main {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-around;
background-color: var(--bg-color);
overflow: auto;
color: var(--txt-color);
font-family: sans-serif;
font-size: large;
}
button {
font-weight: bold;
font-size: medium;
border: none;
border-radius: 4px;
color: var(--txt-color);
background-color: var(--primary-btn-color);
cursor: pointer;
}
input {
border: none;
border-radius: 4px;
background-color: white;
color: black;
}
input:focus,input:focus-visible {
border: none;
outline: solid var(--line-width) var(--accent-a);
outline-offset: -3px;
}
a:link {
color: var(--accent-a);
}
a:visited {
color: var(--accent-b);
}
"
);
rsx!(
sir::AppStyle { }
match *STATE.status.read() {
Connected => rsx!(ServerView {}),
_ => rsx!(LoginView {}),
}
)
}
-425
View File
@@ -1,425 +0,0 @@
use crate::app::Command;
use crate::bail;
use dioxus::prelude::*;
use futures::AsyncRead;
use futures::AsyncWrite;
use futures_channel::mpsc::UnboundedSender;
use gloo_timers::future::TimeoutFuture;
use manganis::mg;
use mumble_protocol::control::ClientControlCodec;
use mumble_protocol::control::ControlPacket;
use mumble_protocol::voice::VoicePacket;
use mumble_protocol::voice::VoicePacketPayload;
use mumble_protocol::Serverbound;
use std::fmt;
use std::io;
use std::time::Duration;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::console;
use web_sys::js_sys::Promise;
use web_sys::js_sys::Reflect;
use web_sys::js_sys::Uint8Array;
use web_sys::window;
use web_sys::AudioContext;
use web_sys::AudioContextOptions;
use web_sys::AudioData;
use web_sys::AudioDecoder;
use web_sys::AudioDecoderConfig;
use web_sys::AudioDecoderInit;
use web_sys::AudioEncoder;
use web_sys::AudioEncoderConfig;
use web_sys::AudioEncoderInit;
use web_sys::AudioWorkletNode;
use web_sys::EncodedAudioChunk;
use web_sys::EncodedAudioChunkInit;
use web_sys::EncodedAudioChunkType;
use web_sys::MediaStream;
use web_sys::MediaStreamConstraints;
use web_sys::MediaStreamTrackGenerator;
use web_sys::MediaStreamTrackGeneratorInit;
use web_sys::MessageEvent;
use web_sys::WebTransport;
use web_sys::WebTransportBidirectionalStream;
use web_sys::WebTransportOptions;
use web_sys::WorkletOptions;
pub use wasm_bindgen_futures::spawn_local as spawn;
pub trait ImpRead: AsyncRead + Unpin + 'static {}
impl<T: AsyncRead + Unpin + 'static> ImpRead for T {}
pub trait ImpWrite: AsyncWrite + Unpin + 'static {}
impl<T: AsyncWrite + Unpin + 'static> ImpWrite for T {}
pub async fn sleep(d: Duration) {
TimeoutFuture::new(d.as_millis() as u32).await
}
pub struct Error(JsValue);
impl From<anyhow::Error> for Error {
fn from(value: anyhow::Error) -> Self {
Error(JsError::new(&value.to_string()).into())
}
}
impl From<io::Error> for Error {
fn from(value: io::Error) -> Self {
Error(JsError::new(&value.to_string()).into())
}
}
impl From<JsValue> for Error {
fn from(value: JsValue) -> Self {
Error(value)
}
}
impl From<JsError> for Error {
fn from(value: JsError) -> Self {
Error(JsError::from(value).into())
}
}
impl Error {
pub fn new(text: String) -> Self {
wasm_bindgen::JsError::new(&text).into()
}
pub fn log(&self) {
console::error_1(&self.0);
}
}
impl std::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text: String = js_sys::Object::from(self.0.clone()).to_string().into();
f.write_str(&text)
}
}
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text: String = js_sys::Object::from(self.0.clone()).to_string().into();
f.write_str(&text)
}
}
pub struct AudioSystem(AudioContext);
impl AudioSystem {
pub fn new(sender: UnboundedSender<ControlPacket<Serverbound>>) -> Result<Self, Error> {
// Create MediaStreams to playback decoded audio
// The audio context is used to reproduce audio.
let audio_context = configure_audio_context();
let audio_context_worklet = audio_context.clone();
spawn(async move {
match create_encoder_worklet(&audio_context_worklet, sender).await {
Ok(node) => console::log_2(&"Created audio worklet:".into(), &node),
Err(err) => err.log(),
}
});
Ok(AudioSystem(audio_context))
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
let audio_context = &self.0;
let audio_stream_generator =
MediaStreamTrackGenerator::new(&MediaStreamTrackGeneratorInit::new("audio"))?;
// Create MediaStream from MediaStreamTrackGenerator
let js_tracks = web_sys::js_sys::Array::new();
js_tracks.push(&audio_stream_generator);
let media_stream = MediaStream::new_with_tracks(&js_tracks)?;
// Create MediaStreamAudioSourceNode
let audio_source = audio_context.create_media_stream_source(&media_stream)?;
// Connect output of audio_source to audio_context (browser audio)
audio_source.connect_with_audio_node(&audio_context.destination())?;
// Create callback functions for AudioDecoder
let error = Closure::wrap(Box::new(move |e: JsValue| {
console::error_1(&e);
}) as Box<dyn FnMut(JsValue)>);
// This knows what MediaStreamTrackGenerator to use as it closes around it
let output = Closure::wrap(Box::new(move |audio_data: AudioData| {
let writable = audio_stream_generator.writable();
if writable.locked() {
return;
}
if let Err(e) = writable.get_writer().map(|writer| {
spawn(async move {
if let Err(e) = JsFuture::from(writer.ready()).await {
console::error_1(&format!("write chunk ready error {:?}", e).into());
}
if let Err(e) = JsFuture::from(writer.write_with_chunk(&audio_data)).await {
console::error_1(&format!("write chunk error {:?}", e).into());
};
writer.release_lock();
});
}) {
console::error_1(&e);
}
}) as Box<dyn FnMut(AudioData)>);
let audio_decoder = AudioDecoder::new(&AudioDecoderInit::new(
error.as_ref().unchecked_ref(),
output.as_ref().unchecked_ref(),
))?;
audio_decoder.configure(&AudioDecoderConfig::new("opus", 1, 48000));
console::log_1(&"Created Audio Decoder".into());
// This is required to prevent these from being deallocated
error.forget();
output.forget();
Ok(AudioPlayer(audio_decoder))
}
}
pub struct AudioPlayer(AudioDecoder);
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
let js_audio_payload = Uint8Array::from(payload);
let _ = self.0.decode(
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
&js_audio_payload.into(),
0.0,
EncodedAudioChunkType::Key,
))
.unwrap(),
);
}
}
// Borrowed from
// https://github.com/security-union/videocall-rs/blob/main/videocall-client/src/decode/config.rs#L6
fn configure_audio_context() -> AudioContext {
let mut audio_context_options = AudioContextOptions::new();
audio_context_options.sample_rate(48000 as f32);
let audio_context = AudioContext::new_with_context_options(&audio_context_options).unwrap();
audio_context
}
trait PromiseExt {
fn into_future(self) -> JsFuture;
}
impl PromiseExt for Promise {
fn into_future(self) -> JsFuture {
self.into()
}
}
async fn create_encoder_worklet(
audio_context: &AudioContext,
packets: UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
) -> Result<AudioWorkletNode, Error> {
let stream = window()
.unwrap()
.navigator()
.media_devices()?
.get_user_media_with_constraints(MediaStreamConstraints::new().audio(&JsValue::TRUE))?
.into_future()
.await?
.dyn_into()
.map_err(|e| JsError::new(&format!("not a stream: {e:?}")))?;
let options = WorkletOptions::new();
Reflect::set(
&options,
&"processorOptions".into(),
&wasm_bindgen::module(),
)?;
let module = "/rust_mic_worklet.js";
console::log_1(&format!("Loading mic worklet from {module:?}").into());
audio_context
.audio_worklet()?
.add_module_with_options(module, &options)?
.into_future()
.await?;
let source = audio_context.create_media_stream_source(&stream)?;
let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet")?;
let error: Closure<dyn FnMut(JsValue)> = Closure::new(|e| console::error_1(&e));
let download_buffer = std::cell::RefCell::new(Vec::new());
// This knows what MediaStreamTrackGenerator to use as it closes around it
let mut sequence_num = 0;
let output: Closure<dyn FnMut(EncodedAudioChunk)> =
Closure::new(move |audio_data: EncodedAudioChunk| {
let mut array = vec![0u8; audio_data.byte_length() as usize];
audio_data.copy_to_with_u8_slice(&mut array);
download_buffer.borrow_mut().push(array.clone());
if download_buffer.borrow().len() > 200 {
//download_data(download_buffer.borrow().to_vec(), "download_buffer.opus");
//download_data(
// ass::encode(download_buffer.borrow().to_vec(), 960, 0),
// "download_buffer.opus",
//);
download_buffer.borrow_mut().clear();
}
let _ =
packets.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
_dst: std::marker::PhantomData,
target: 0,
session_id: (),
seq_num: sequence_num,
payload: VoicePacketPayload::Opus(array.into(), false),
position_info: None,
})));
sequence_num = sequence_num.wrapping_add(2);
});
let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new(
error.as_ref().unchecked_ref(),
output.as_ref().unchecked_ref(),
))
.unwrap();
// This is required to prevent these from being deallocated
error.forget();
output.forget();
let encoder_config = AudioEncoderConfig::new("opus");
encoder_config.set_number_of_channels(1);
encoder_config.set_sample_rate(48000);
encoder_config.set_bitrate(72_000.0);
audio_encoder.configure(&encoder_config);
console::log_1(&"Created Audio Encoder".into());
let download_buffer = std::cell::RefCell::new(Vec::new());
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
match AudioData::new(event.data().unchecked_ref()) {
Ok(data) => {
let x = web_sys::AudioDataCopyToOptions::new(0);
x.set_format(web_sys::AudioSampleFormat::F32);
let mut sub_buffer = vec![0; data.allocation_size(&x).unwrap() as usize];
data.copy_to_with_u8_slice(&mut sub_buffer, &x);
download_buffer.borrow_mut().append(&mut sub_buffer);
if download_buffer.borrow().len() > 48000 * 10 * 4 {
//pub fn download_data(data: Vec<u8>, filename: &str) -> Result<(), JsValue> {
//download_data(download_buffer.borrow().to_vec(), "download_buffer.pcm32");
download_buffer.borrow_mut().clear();
}
audio_encoder.encode(&data);
}
Err(err) => {
console::error_1(&err);
console::debug_1(&event);
}
}
});
Reflect::set(
&Reflect::get(&worklet_node, &"port".into())?,
&"onmessage".into(),
onmessage.as_ref(),
)?;
onmessage.forget();
source.connect_with_audio_node(&worklet_node)?;
worklet_node.connect_with_audio_node(&audio_context.destination())?;
Ok(worklet_node)
}
pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
) -> Result<(), Error> {
console::log_1(&"Rust via WASM!".into());
let Ok(server_hash): Result<Vec<u8>, _> = include_str!("../../server_hash.txt")
.trim()
.trim_matches(&['[', ']'])
.split(',')
.map(|x| x.trim().parse())
.collect()
else {
bail!("could not parse server hash");
};
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
let object = web_sys::js_sys::Object::new();
Reflect::set(
&object,
&JsValue::from_str("algorithm"),
&JsValue::from_str("sha-256"),
)?;
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash)?;
let array = web_sys::js_sys::Array::new();
array.push(&object);
console::log_1(&object.clone().into());
console::log_1(&"Created option object!".into());
let mut options = WebTransportOptions::new();
options.server_certificate_hashes(&array);
console::log_1(&"Created WebTransportOptions!".into());
let transport = WebTransport::new_with_options(&address, &options)?;
console::log_1(&"Created WebTransport connection object.".into());
console::log_1(&transport.clone().into());
if let Err(e) = wasm_bindgen_futures::JsFuture::from(transport.ready()).await {
bail!("could not connect to transport: {e:?}");
}
console::log_1(&"Transport is ready.".into());
let stream: WebTransportBidirectionalStream =
wasm_bindgen_futures::JsFuture::from(transport.create_bidirectional_stream())
.await?
.into();
let wasm_stream_readable = wasm_streams::ReadableStream::from_raw(stream.readable().into());
let wasm_stream_writable = wasm_streams::WritableStream::from_raw(stream.writable().into());
let read_codec = ClientControlCodec::new();
let write_codec = ClientControlCodec::new();
let reader =
asynchronous_codec::FramedRead::new(wasm_stream_readable.into_async_read(), read_codec);
let writer =
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
super::network_loop(username, event_rx, reader, writer).await
}
pub fn set_default_username(username: &str) -> Option<()> {
web_sys::window()?
.local_storage()
.ok()??
.set_item("username", username)
.ok()
}
pub fn load_username() -> Option<String> {
web_sys::window()
.unwrap()
.local_storage()
.ok()??
.get_item("username")
.ok()?
}
-11
View File
@@ -1,11 +0,0 @@
use mumble_web2::app;
pub fn main() {
#[cfg(feature = "desktop")]
let _guard = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.enter();
dioxus::launch(app::app);
}