Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51f05593ac | |||
| eab8f1d6a7 | |||
| 4abb130a77 | |||
| af35d72e4e | |||
| 889bdf6b80 | |||
| 391d18a11e | |||
| ca8a3d1b92 | |||
| 5d2c2a93c7 | |||
| 96589e28c6 | |||
| e7e7b945c5 | |||
| cd90cb628b | |||
| 37613a65c4 | |||
| d6b482528f | |||
| 5df7b0e082 | |||
| 55412f5778 | |||
| 2982a7f8d8 | |||
| b1970cf23f | |||
| a11fb4f10e | |||
| 40bb8e18ef | |||
| 128791bccd | |||
| e8d41f7e92 | |||
| 1e28442356 | |||
| c645722d21 | |||
| b9e293cca4 | |||
| 97bdb1a143 | |||
| 70634065ac | |||
| 1efd32892e | |||
| 1ff302816e | |||
| ebcf5ce4ce | |||
| 4e30be3ebd | |||
| 987cfd57d2 | |||
| fea6800bea | |||
| f2bdc665f5 | |||
| 61f3a4e623 | |||
| 260decc9af | |||
| cfb8144561 | |||
| b8a201911f | |||
| 134e42e69f | |||
| 55a91b1459 | |||
| d9695be153 | |||
| 20ec64cf1c | |||
| 1793504467 | |||
| 74fe399cdc | |||
| dd65b238d1 | |||
| de0e41ec85 | |||
| 0462340694 | |||
| 0b928c171f | |||
| a98bc825f6 | |||
| 980e8c2620 | |||
| bcd73ae83f | |||
| b2ee911c66 | |||
| b65ec274d8 | |||
| a25cf64681 | |||
| 3c6a436690 | |||
| 80aedc7269 | |||
| efe842f671 | |||
| 70fcd18690 | |||
| 2211be5324 | |||
| 30a94323b3 | |||
| e1f3bca708 | |||
| 7308a210e2 | |||
| 105deab45d | |||
| 4055bf24ab | |||
| 95c57c4850 | |||
| b19f629605 | |||
| b351f2fe13 | |||
| 30045dd6bd | |||
| 1d8f3fd791 | |||
| 206bf23bdf | |||
| 2d3f31754b | |||
| abd2a2f81c | |||
| b2cae01bf8 | |||
| 725db06703 |
@@ -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,85 @@
|
||||
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
|
||||
@@ -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
|
||||
@@ -1,3 +1,9 @@
|
||||
/target
|
||||
dist/
|
||||
server_hash.txt
|
||||
.aider*
|
||||
**.pem
|
||||
proxy/bundle
|
||||
/config.toml
|
||||
proxy/config.toml
|
||||
gui/assets/*_onnx.tar.gz
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[language-server.rust-analyzer]
|
||||
config = { cargo = { features = "all" } }
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"rust-analyzer.cargo.features": ["desktop","web"],
|
||||
"rust-analyzer.cargo.noDefaultFeatures": false
|
||||
}
|
||||
@@ -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",
|
||||
] }
|
||||
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"
|
||||
|
||||
@@ -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`
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "mumble-web2-common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,17 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
pub struct ProxyOverides {
|
||||
pub proxy_url: String,
|
||||
pub cert_hash: Vec<u8>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:latest
|
||||
ports:
|
||||
- "64444:64444/tcp"
|
||||
- "64444:64444/udp"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
#- 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
|
||||
- ./proxy-config.toml:/app/config.toml
|
||||
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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,135 @@
|
||||
[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"
|
||||
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false }
|
||||
base64 = "^0.22"
|
||||
mime_guess = "^2.0.5"
|
||||
async_cell = "^0.2.3"
|
||||
reqwest = { version = "^0.12.22", features = ["json"] }
|
||||
dioxus-asset-resolver = "0.7.2"
|
||||
|
||||
# Denoising
|
||||
# =========
|
||||
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
|
||||
"tract",
|
||||
] }
|
||||
crossbeam = "0.8.4"
|
||||
|
||||
[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",
|
||||
]
|
||||
desktop = [
|
||||
"dioxus/desktop",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing-subscriber/env-filter",
|
||||
"opus",
|
||||
"cpal",
|
||||
"dasp_ring_buffer",
|
||||
"rfd/xdg-portal",
|
||||
"etcetera",
|
||||
]
|
||||
@@ -1,46 +1,44 @@
|
||||
[application]
|
||||
|
||||
# App (Project) Name
|
||||
name = "Mumble Web 2"
|
||||
|
||||
# Dioxus App Default Platform
|
||||
# desktop, web, mobile, ssr
|
||||
default_platform = "web"
|
||||
|
||||
# `build` & `serve` dist path
|
||||
out_dir = "dist"
|
||||
|
||||
# resource (public) file folder
|
||||
asset_dir = "public"
|
||||
|
||||
[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
|
||||
|
||||
# CSS style file
|
||||
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",
|
||||
]
|
||||
|
After Width: | Height: | Size: 288 KiB |
@@ -0,0 +1,310 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
.button_row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle_button {
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
background-color: unset;
|
||||
|
||||
border: solid rgb(255 255 255 / 0.1) 3px;
|
||||
border-radius: 10px;
|
||||
color: rgb(255 255 255 / 50%);
|
||||
|
||||
transition: all 0.5s ease-in-out;
|
||||
|
||||
&.is_on {
|
||||
background-color: oklch(0.5 0.1381 21.71 / 20.12%);
|
||||
color: oklch(0.53 0.1505 21.71 / 89.38%);
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
||||
vertical-align: middle;
|
||||
font-size: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
.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: 16px;
|
||||
margin: 16px;
|
||||
background-color: var(--light-bg-color);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
grid-area: control;
|
||||
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
|
||||
<g fill="#fff">
|
||||
|
||||
<path d="M8 0c-.78 0-1.538.29-2.104.821a2.862 2.862 0 00-.627.857.75.75 0 001.354.644c.07-.147.17-.286.3-.407A1.578 1.578 0 018 1.5c.413 0 .8.154 1.078.415.276.26.422.601.422.946v3.443a.75.75 0 001.5 0V2.861c0-.775-.329-1.507-.896-2.04A3.077 3.077 0 008 0z"/>
|
||||
|
||||
<path fill-rule="evenodd" d="M5 6.06L1.22 2.28a.75.75 0 011.06-1.06l12.5 12.5a.75.75 0 11-1.06 1.06L11.338 12.4a5.575 5.575 0 01-2.588 1.05V14.5h1.75a.75.75 0 010 1.5h-5a.75.75 0 010-1.5h1.75v-1.05a5.553 5.553 0 01-3.131-1.514A5.3 5.3 0 012.5 8.135V6.75a.75.75 0 011.5 0v1.385a3.8 3.8 0 001.164 2.725A4.071 4.071 0 008 12c.815 0 1.602-.24 2.262-.677l-.726-.726A3.113 3.113 0 018 11c-.78 0-1.538-.29-2.104-.821A2.797 2.797 0 015 8.139V6.06zm1.5 1.5v.579c0 .345.146.686.422.946.278.26.665.415 1.078.415.134 0 .266-.016.392-.047L6.5 7.56z" clip-rule="evenodd"/>
|
||||
|
||||
<path d="M12.03 6.75a.75.75 0 011.5 0v1.385c0 .266-.02.53-.06.79a.75.75 0 11-1.483-.227c.029-.185.043-.374.043-.563V6.75z"/>
|
||||
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
|
||||
<g fill="#fff">
|
||||
|
||||
<path fill-rule="evenodd" d="M8 0c-.78 0-1.538.29-2.104.821A2.797 2.797 0 005 2.861V8.14c0 .775.328 1.507.896 2.04.566.53 1.323.821 2.104.821.78 0 1.538-.29 2.104-.821A2.797 2.797 0 0011 8.139V2.86c0-.775-.329-1.507-.896-2.04A3.077 3.077 0 008 0zM6.922 1.915A1.578 1.578 0 018 1.5c.413 0 .8.154 1.078.415.276.26.422.601.422.946V8.14c0 .345-.146.686-.422.946A1.578 1.578 0 018 9.5c-.413 0-.8-.154-1.078-.415-.276-.26-.422-.601-.422-.946V2.86c0-.345.146-.686.422-.946z" clip-rule="evenodd"/>
|
||||
|
||||
<path d="M4 6.75a.75.75 0 00-1.5 0v1.385a5.3 5.3 0 001.619 3.801A5.553 5.553 0 007.25 13.45v1.05H5.5a.75.75 0 000 1.5h5a.75.75 0 000-1.5H8.75v-1.05a5.553 5.553 0 003.131-1.514A5.3 5.3 0 0013.5 8.135V6.75a.75.75 0 00-1.5 0v1.385a3.8 3.8 0 01-1.164 2.725A4.071 4.071 0 018 12a4.071 4.071 0 01-2.836-1.14A3.8 3.8 0 014 8.135V6.75z"/>
|
||||
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
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);
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.0355 8.52113C13.4261 8.1306 14.0674 8.12674 14.3881 8.57637C15.0882 9.55788 15.5 10.7592 15.5 12.0567C15.5 13.3541 15.0882 14.5554 14.3881 15.537C14.0674 15.9866 13.4261 15.9827 13.0355 15.5922C12.645 15.2017 12.6586 14.5725 12.9408 14.0978C13.296 13.5002 13.5 12.8023 13.5 12.0567C13.5 11.3111 13.296 10.6131 12.9408 10.0156C12.6586 9.54084 12.645 8.91165 13.0355 8.52113Z" fill="#fff"/>
|
||||
<path d="M15.864 5.69316C16.2545 5.30263 16.8921 5.29976 17.2419 5.72712C18.6532 7.45118 19.5 9.65526 19.5 12.0571C19.5 14.459 18.6532 16.6631 17.2419 18.3871C16.8921 18.8145 16.2545 18.8116 15.864 18.4211C15.4734 18.0306 15.4792 17.4007 15.8183 16.9648C16.8723 15.6098 17.5 13.9068 17.5 12.0571C17.5 10.2075 16.8723 8.50445 15.8183 7.14944C15.4792 6.71351 15.4734 6.08368 15.864 5.69316Z" fill="#fff"/>
|
||||
<path d="M11 16.5858V7.41421C11 6.52331 9.92286 6.07714 9.29289 6.70711L7.29289 8.70711C7.10536 8.89464 6.851 9 6.58579 9H5C4.44772 9 4 9.44772 4 10V14C4 14.5523 4.44772 15 5 15H6.58579C6.851 15 7.10536 15.1054 7.29289 15.2929L9.29289 17.2929C9.92286 17.9229 11 17.4767 11 16.5858Z" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 16.5858V7.41421C11 6.52331 9.92286 6.07714 9.29289 6.70711L7.29289 8.70711C7.10536 8.89464 6.851 9 6.58579 9H5C4.44772 9 4 9.44772 4 10V14C4 14.5523 4.44772 15 5 15H6.58579C6.851 15 7.10536 15.1054 7.29289 15.2929L9.29289 17.2929C9.92286 17.9229 11 17.4767 11 16.5858Z" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M20 9.5L15 14.5" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M20 14.5L15 9.5" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 737 B |
@@ -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();
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 967 B |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 164 KiB |
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,748 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use mime_guess::Mime;
|
||||
use mumble_web2_common::{ProxyOverides, 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: ProxyOverides,
|
||||
},
|
||||
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 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<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 => 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::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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[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<Option<ProxyOverides>>) -> Element {
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
let status = &STATE.status;
|
||||
let server = STATE.server.read();
|
||||
let Some(&UserState {
|
||||
deaf,
|
||||
self_deaf,
|
||||
mute,
|
||||
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(|opt| opt.as_ref())
|
||||
.map(|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 {
|
||||
style: "color: \"{connecting_color}\";",
|
||||
span {
|
||||
class: "material-symbols-outlined",
|
||||
style: "vertical-align: middle; font-size: 30px;",
|
||||
"signal_cellular_alt_2_bar"
|
||||
}
|
||||
span {
|
||||
style: "width: 5px; display: inline-block;"
|
||||
}
|
||||
span {
|
||||
style: "vertical-align: middle; font-size: 30px;",
|
||||
"Connecting"
|
||||
}
|
||||
}
|
||||
},
|
||||
Connected => rsx! {
|
||||
div {
|
||||
div {
|
||||
style: "color: \"{connected_color}\";",
|
||||
span {
|
||||
class: "material-symbols-outlined",
|
||||
style: "vertical-align: middle; font-size: 30px;",
|
||||
"signal_cellular_alt"
|
||||
}
|
||||
span {
|
||||
style: "width: 5px; display: inline-block;"
|
||||
}
|
||||
span {
|
||||
style: "vertical-align: middle; font-size: 25px;",
|
||||
"Connected"
|
||||
}
|
||||
}
|
||||
div {
|
||||
span { style: "width: 3px; display: inline-block;"}
|
||||
span { "{current_channel_name}" }
|
||||
if let Some(proxy_url) = proxy_url {
|
||||
span { " — " }
|
||||
span { "{proxy_url}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Disconnected => rsx! {
|
||||
div {
|
||||
style: "color: \"{disconnected_color}\";",
|
||||
span {
|
||||
class: "material-symbols-outlined",
|
||||
style: "vertical-align: middle;",
|
||||
"signal_disconnected"
|
||||
}
|
||||
span {
|
||||
style: "width: 5px; display: inline-block;"
|
||||
}
|
||||
span {
|
||||
style: "vertical-align: middle;",
|
||||
"Disconnected"
|
||||
}
|
||||
}
|
||||
},
|
||||
Failed(_) => rsx! {
|
||||
div {
|
||||
style: "color: \"{failed_color}\";",
|
||||
span {
|
||||
class: "material-symbols-outlined",
|
||||
style: "vertical-align: middle;",
|
||||
"error"
|
||||
}
|
||||
span {
|
||||
style: "width: 5px; display: inline-block;"
|
||||
}
|
||||
span {
|
||||
style: "vertical-align: middle;",
|
||||
"Failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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); font-size: 45px; font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;",
|
||||
"person_edit"
|
||||
}
|
||||
}
|
||||
div {
|
||||
div {
|
||||
span { style: "font-size: 25px;", "{name}" }
|
||||
}
|
||||
div {
|
||||
span { style: "font-size: 20px; color: gray;", "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 || self_mute {
|
||||
true => "toggle_button is_on",
|
||||
false => "toggle_button",
|
||||
},
|
||||
role: "switch",
|
||||
aria_checked: mute || self_mute,
|
||||
disabled: mute,
|
||||
onclick: move |_| net.send(SetMute { mute: !self_mute }),
|
||||
match mute || 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<Option<ProxyOverides>>) -> 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<Option<ProxyOverides>>) -> 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()
|
||||
.flatten()
|
||||
.map(|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(|opt| opt.is_none()) {
|
||||
imp::set_default_server(&address.read());
|
||||
}
|
||||
net.send(Connect {
|
||||
address: address.read().clone(),
|
||||
username: username.read().clone(),
|
||||
config: config.read().clone().flatten().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(|opt| opt.is_none()) {
|
||||
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_proxy_overides().await {
|
||||
Ok(config) => config,
|
||||
Err(_) => None,
|
||||
}
|
||||
});
|
||||
|
||||
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 }
|
||||
|
||||
if config.read().is_none() {
|
||||
div { class: "loading", "Loading..." }
|
||||
} else {
|
||||
match *STATE.status.read() {
|
||||
Connected => rsx!(ServerView { config }),
|
||||
_ => rsx!(LoginView { config }),
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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");
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
impl AudioProcessor {
|
||||
pub fn new_plain() -> Self {
|
||||
AudioProcessor {
|
||||
denoise: false,
|
||||
spawn: imp::SpawnHandle::current(),
|
||||
buffer: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_denoising() -> Self {
|
||||
AudioProcessor {
|
||||
denoise: true,
|
||||
spawn: imp::SpawnHandle::current(),
|
||||
buffer: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioProcessor {
|
||||
pub fn process(&mut self, audio: &[f32], channels: usize, output: &mut Vec<f32>) {
|
||||
let mut include_raw = true;
|
||||
if self.denoise {
|
||||
with_denoising_model(&self.spawn, |df| {
|
||||
include_raw = false;
|
||||
|
||||
self.buffer.extend(audio.iter().step_by(channels).copied());
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type AudioProcessorSender = Arc<AtomicCell<Option<AudioProcessor>>>;
|
||||
@@ -0,0 +1,390 @@
|
||||
use crate::app::Command;
|
||||
use crate::effects::{AudioProcessor, AudioProcessorSender};
|
||||
use color_eyre::eyre::{bail, eyre, Error};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
|
||||
use dioxus::hooks::UnboundedReceiver;
|
||||
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
|
||||
use futures::io::{AsyncRead, AsyncWrite};
|
||||
use mumble_protocol::control::ClientControlCodec;
|
||||
use mumble_web2_common::{ProxyOverides, ServerStatus};
|
||||
use std::collections::HashMap;
|
||||
use std::mem::replace;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::rustls;
|
||||
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
|
||||
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use tokio_rustls::rustls::ClientConfig as RlsClientConfig;
|
||||
use tokio_rustls::rustls::DigitallySignedStruct;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
|
||||
use tracing::{error, info, instrument, warn};
|
||||
|
||||
pub use tokio::runtime::Handle as SpawnHandle;
|
||||
pub use tokio::task::spawn;
|
||||
pub use tokio::time::sleep;
|
||||
|
||||
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;
|
||||
|
||||
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
|
||||
|
||||
impl AudioSystem {
|
||||
pub async fn new() -> Result<Self, Error> {
|
||||
// TODO
|
||||
let host = cpal::default_host();
|
||||
let name = host.id();
|
||||
let processors = AudioProcessorSender::default();
|
||||
Ok(AudioSystem {
|
||||
output: host
|
||||
.default_output_device()
|
||||
.ok_or(eyre!("no output devices from {name:?}"))?,
|
||||
input: host
|
||||
.default_input_device()
|
||||
.ok_or(eyre!("no input devices from {name:?}"))?,
|
||||
processors,
|
||||
recording_stream: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_processor(&self, processor: AudioProcessor) {
|
||||
self.processors.store(Some(processor))
|
||||
}
|
||||
|
||||
fn choose_config(
|
||||
&self,
|
||||
configs: impl Iterator<Item = cpal::SupportedStreamConfigRange>,
|
||||
) -> Result<cpal::StreamConfig, Error> {
|
||||
let mut supported_configs: Vec<_> = configs
|
||||
.filter_map(|cfg| cfg.try_with_sample_rate(cpal::SampleRate(SAMPLE_RATE)))
|
||||
.filter(|cfg| cfg.sample_format() == cpal::SampleFormat::I16)
|
||||
.map(|cfg| cpal::StreamConfig {
|
||||
buffer_size: cpal::BufferSize::Fixed(match *cfg.buffer_size() {
|
||||
cpal::SupportedBufferSize::Range { min, max } => 480.clamp(min, max),
|
||||
cpal::SupportedBufferSize::Unknown => 480,
|
||||
}),
|
||||
..cfg.config()
|
||||
})
|
||||
.collect();
|
||||
supported_configs.sort_by(|a, b| {
|
||||
let cpal::BufferSize::Fixed(a_buf) = a.buffer_size else {
|
||||
unreachable!()
|
||||
};
|
||||
let cpal::BufferSize::Fixed(b_buf) = b.buffer_size else {
|
||||
unreachable!()
|
||||
};
|
||||
Ord::cmp(&a.channels, &b.channels).then(Ord::cmp(&a_buf, &b_buf))
|
||||
});
|
||||
supported_configs
|
||||
.get(0)
|
||||
.cloned()
|
||||
.ok_or(eyre!("no supported stream configs"))
|
||||
}
|
||||
|
||||
pub fn start_recording(
|
||||
&mut self,
|
||||
mut each: impl FnMut(Vec<u8>) + Send + 'static,
|
||||
) -> Result<(), Error> {
|
||||
let config = self.choose_config(self.input.supported_input_configs()?)?;
|
||||
info!(
|
||||
"creating recording on {:?} with {:#?}",
|
||||
self.input.name()?,
|
||||
config
|
||||
);
|
||||
let mut encoder =
|
||||
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
|
||||
let mut current_processor = AudioProcessor::new_plain();
|
||||
let mut output_buffer = Vec::new();
|
||||
let processors = self.processors.clone();
|
||||
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
|
||||
let data_callback = move |frame: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
if let Some(new_processor) = processors.take() {
|
||||
current_processor = new_processor;
|
||||
}
|
||||
current_processor.process(frame, config.channels as usize, &mut output_buffer);
|
||||
if output_buffer.len() < PACKET_SAMPLES as usize {
|
||||
return;
|
||||
}
|
||||
let remainder = output_buffer.split_off(PACKET_SAMPLES as usize);
|
||||
let frame = replace(&mut output_buffer, remainder);
|
||||
match encoder.encode_vec_float(&frame, frame.len() * 2) {
|
||||
Ok(buf) => {
|
||||
each(buf);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("error encoding {} samples: {e:?}", frame.len());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match self
|
||||
.input
|
||||
.build_input_stream(&config, data_callback, error_callback, None)
|
||||
{
|
||||
Ok(stream) => {
|
||||
stream.play()?;
|
||||
self.recording_stream = Some(stream);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
self.recording_stream = None;
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
|
||||
let config = self.choose_config(self.input.supported_input_configs()?)?;
|
||||
info!(
|
||||
"creating player on {:?} with {:#?}",
|
||||
self.output.name().ok(),
|
||||
&config
|
||||
);
|
||||
let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts(
|
||||
0,
|
||||
0,
|
||||
vec![
|
||||
0;
|
||||
SAMPLE_RATE as usize/4 // 250ms of buffer
|
||||
],
|
||||
)));
|
||||
let decoder = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Mono)?;
|
||||
let stream = {
|
||||
let buffer = buffer.clone();
|
||||
self.output.build_output_stream(
|
||||
&config,
|
||||
move |frame, _info| {
|
||||
let mut buffer = buffer.lock().unwrap();
|
||||
for x in frame.chunks_mut(config.channels as usize) {
|
||||
match buffer.pop() {
|
||||
Some(y) => {
|
||||
x.fill(y);
|
||||
}
|
||||
None => {
|
||||
x.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
move |err| error!("could not create output stream {err:?}"),
|
||||
None,
|
||||
)?
|
||||
};
|
||||
stream.play()?;
|
||||
Ok(AudioPlayer {
|
||||
decoder,
|
||||
stream,
|
||||
buffer,
|
||||
tmp: vec![0; 2400],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioPlayer {
|
||||
decoder: opus::Decoder,
|
||||
stream: cpal::Stream,
|
||||
buffer: Buffer,
|
||||
tmp: Vec<i16>,
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
pub fn play_opus(&mut self, payload: &[u8]) {
|
||||
let len = loop {
|
||||
match self.decoder.decode(payload, &mut self.tmp, false) {
|
||||
Ok(l) => break l,
|
||||
Err(e) => {
|
||||
error!("opus decode error {e:?}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut buffer = self.buffer.lock().unwrap();
|
||||
let mut overrun = 0;
|
||||
for x in &self.tmp[..len] {
|
||||
if let Some(_) = buffer.push(*x) {
|
||||
overrun += 1;
|
||||
}
|
||||
}
|
||||
if overrun > 0 {
|
||||
warn!("playback overrun by {overrun} samples");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NoCertificateVerification;
|
||||
|
||||
impl ServerCertVerifier for NoCertificateVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA1,
|
||||
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::ED448,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
pub async fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
gui_config: &ProxyOverides,
|
||||
) -> Result<(), Error> {
|
||||
info!("connecting");
|
||||
|
||||
let config = RlsClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
|
||||
let addr = format!("{}:{}", address, 64738)
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let server_tcp = TcpStream::connect(addr).await?;
|
||||
let server_stream = connector
|
||||
//.connect("127.0.0.1".try_into()?, server_tcp)
|
||||
.connect(address.try_into()?, server_tcp)
|
||||
.await?;
|
||||
let (read_server, write_server) = tokio::io::split(server_stream);
|
||||
|
||||
let read_codec = ClientControlCodec::new();
|
||||
let write_codec = ClientControlCodec::new();
|
||||
|
||||
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
|
||||
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
|
||||
|
||||
crate::network_loop(username, event_rx, reader, writer).await
|
||||
}
|
||||
|
||||
fn get_config_path() -> std::path::PathBuf {
|
||||
let strategy = choose_app_strategy(AppStrategyArgs {
|
||||
top_level_domain: "com".to_string(),
|
||||
author: "Ohea Corp".to_string(),
|
||||
app_name: "Mumble Web2".to_string(),
|
||||
})
|
||||
.expect("failed to choose app strategy");
|
||||
strategy.config_dir().join("config.json")
|
||||
}
|
||||
|
||||
fn load_config_map() -> HashMap<String, String> {
|
||||
let config_path = get_config_path();
|
||||
match std::fs::read_to_string(&config_path) {
|
||||
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> {
|
||||
let config_path = get_config_path();
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let contents = serde_json::to_string_pretty(config)?;
|
||||
std::fs::write(&config_path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_default_username(username: &str) -> Option<()> {
|
||||
let mut config = load_config_map();
|
||||
config.insert("username".to_string(), username.to_string());
|
||||
save_config_map(&config).ok()
|
||||
}
|
||||
|
||||
pub fn set_default_server(server: &str) -> Option<()> {
|
||||
let mut config = load_config_map();
|
||||
config.insert("server".to_string(), server.to_string());
|
||||
save_config_map(&config).ok()
|
||||
}
|
||||
|
||||
pub fn load_username() -> Option<String> {
|
||||
let config = load_config_map();
|
||||
config.get("username").cloned()
|
||||
}
|
||||
|
||||
pub fn load_server_url() -> Option<String> {
|
||||
let config = load_config_map();
|
||||
config.get("server").cloned()
|
||||
}
|
||||
|
||||
pub async fn load_proxy_overides() -> color_eyre::Result<Option<ProxyOverides>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||
bail!("status not supported on desktop yet")
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#[cfg(feature = "web")]
|
||||
mod web;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
mod desktop;
|
||||
|
||||
#[cfg(all(feature = "web", not(feature = "desktop")))]
|
||||
pub use web::*;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub use desktop::*;
|
||||
@@ -0,0 +1,484 @@
|
||||
use crate::app::Command;
|
||||
use crate::effects::{AudioProcessor, AudioProcessorSender};
|
||||
use color_eyre::eyre::{bail, eyre, Error};
|
||||
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::{ProxyOverides, ServerStatus};
|
||||
use reqwest::Url;
|
||||
use std::future::Future;
|
||||
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>) + '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) {
|
||||
let Ok(samples) = Reflect::get(&frame, &"data".into()) else {
|
||||
return;
|
||||
};
|
||||
let Ok(samples) = samples.dyn_into::<Float32Array>() else {
|
||||
return;
|
||||
};
|
||||
let input = samples.to_vec();
|
||||
let mut output = Vec::with_capacity(input.len());
|
||||
processor.process(&input, 1, &mut output);
|
||||
samples.copy_from(&output);
|
||||
}
|
||||
|
||||
async fn run_encoder_worklet(
|
||||
audio_context: &AudioContext,
|
||||
mut each: impl FnMut(Vec<u8>) + '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));
|
||||
|
||||
// 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);
|
||||
each(array);
|
||||
});
|
||||
|
||||
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();
|
||||
process_audio(&frame, &mut current_processor);
|
||||
|
||||
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: &ProxyOverides,
|
||||
) -> 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 !gui_config.cert_hash.is_empty() {
|
||||
let hash = web_sys::js_sys::Uint8Array::from(gui_config.cert_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_proxy_overides() -> color_eyre::Result<Option<ProxyOverides>> {
|
||||
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::<ProxyOverides>()
|
||||
.await?;
|
||||
|
||||
Ok(Some(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);
|
||||
}
|
||||
}
|
||||
@@ -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,46 @@ 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 mumble_web2_common::ProxyOverides;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
|
||||
use crate::effects::AudioProcessor;
|
||||
use crate::imp::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 +69,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 +84,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 +113,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| {
|
||||
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(), false),
|
||||
position_info: None,
|
||||
})));
|
||||
sequence_num = sequence_num.wrapping_add(2);
|
||||
});
|
||||
}
|
||||
|
||||
// Create map of session_id -> AudioDecoder
|
||||
let mut decoder_map = HashMap::new();
|
||||
@@ -126,28 +144,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 +183,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 +225,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 +285,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(())
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use mumble_web2_gui::{app, imp};
|
||||
|
||||
pub fn main() {
|
||||
imp::init_logging();
|
||||
dioxus::launch(app::app);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,435 @@
|
||||
use color_eyre::eyre::{anyhow, bail, Context, Result};
|
||||
use mumble_web2_common::{ProxyOverides, 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 = ProxyOverides {
|
||||
proxy_url: server_config
|
||||
.proxy_url
|
||||
.as_ref()
|
||||
.map(|url| url.to_string())
|
||||
.unwrap_or_default(),
|
||||
cert_hash: Vec::new(),
|
||||
};
|
||||
|
||||
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 = 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: ProxyOverides,
|
||||
}
|
||||
|
||||
#[craft]
|
||||
impl ConfigCraft {
|
||||
#[craft(handler)]
|
||||
async fn get_config(&self) -> Json<ProxyOverides> {
|
||||
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");
|
||||
|
||||
// Spawn tasks to handle transmitting data between the WebTransport client and Mumble TCP Server
|
||||
let c2s = tokio::spawn(
|
||||
pass_bytes_loop(incoming, write_server)
|
||||
.instrument(info_span!("Handler", "Client to server")),
|
||||
);
|
||||
let s2c = tokio::spawn(
|
||||
pass_bytes_loop(read_server, outgoing)
|
||||
.instrument(info_span!("Handler", "Server to client")),
|
||||
);
|
||||
|
||||
tokio::select! {
|
||||
res = c2s => res??,
|
||||
res = s2c => 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();
|
||||
}
|
||||
@@ -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],
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use dioxus::prelude::*;
|
||||
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,
|
||||
}
|
||||
|
||||
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()),
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn UserPill(name: String) -> Element {
|
||||
let pill = css!(
|
||||
"
|
||||
border: solid 1px black;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
width: fit-content;
|
||||
"
|
||||
);
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "{pill}",
|
||||
"{name}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn User(id: UserId) -> Element {
|
||||
let server = STATE.server.read();
|
||||
let state = server.users.get(&id)?;
|
||||
rsx!(UserPill {
|
||||
name: state.name.clone()
|
||||
})
|
||||
}
|
||||
|
||||
#[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%;
|
||||
"
|
||||
);
|
||||
let channel_children = css!(
|
||||
"
|
||||
border-left: solid black 1px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-left: 5px;
|
||||
padding-left: 11px;
|
||||
"
|
||||
);
|
||||
|
||||
rsx!(
|
||||
details {
|
||||
class: "{channel_details}",
|
||||
open: true,
|
||||
summary {
|
||||
ondoubleclick: move |_| net.send(EnterChannel { channel: id, user }),
|
||||
"{state.name}"
|
||||
}
|
||||
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 black 1px;
|
||||
|
||||
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() }
|
||||
}
|
||||
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: white;
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
"#
|
||||
);
|
||||
|
||||
let channel_box = css!(
|
||||
"
|
||||
padding: 16px;
|
||||
border: solid black 1px;
|
||||
overflow: auto;
|
||||
grid-area: tree;
|
||||
"
|
||||
);
|
||||
|
||||
let chat_box = css!(
|
||||
"
|
||||
border: solid black 1px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: chat;
|
||||
"
|
||||
);
|
||||
|
||||
let top_bar = css!(
|
||||
"
|
||||
padding: 16px;
|
||||
border: solid black 1px;
|
||||
grid-area: bar;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
padding: 8px;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "{grid}",
|
||||
div {
|
||||
class: "{top_bar}",
|
||||
button {
|
||||
onclick: move |_| net.send(Disconnect),
|
||||
"Disconnect"
|
||||
}
|
||||
span {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
id: "mute",
|
||||
checked: mute || self_mute,
|
||||
disabled: mute,
|
||||
onchange: move |_| net.send(SetMute { mute: !self_mute }),
|
||||
}
|
||||
label {
|
||||
r#for: "mute",
|
||||
"Mute"
|
||||
}
|
||||
}
|
||||
span {
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
id: "deaf",
|
||||
checked: deaf || self_deaf,
|
||||
disabled: deaf,
|
||||
onchange: move |_| net.send(SetDeaf { deaf: !self_deaf }),
|
||||
}
|
||||
label {
|
||||
r#for: "deaf",
|
||||
"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!(
|
||||
"
|
||||
color: red;
|
||||
pre {
|
||||
color: black;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let login_box = css!(
|
||||
"
|
||||
max-width: 50vw;
|
||||
align-self: center;
|
||||
padding: 16px;
|
||||
background-color: white;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
input,button {
|
||||
padding: 8px;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
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 {
|
||||
onclick: do_connect.clone(),
|
||||
"Connect!"
|
||||
}
|
||||
},
|
||||
Connecting => rsx! {
|
||||
"Connecting..."
|
||||
},
|
||||
Failed(msg) => rsx!(
|
||||
button {
|
||||
onclick: do_connect.clone(),
|
||||
"Reconnect!"
|
||||
}
|
||||
div {
|
||||
class: "{error}",
|
||||
"Failed to connect:"
|
||||
pre {
|
||||
"{msg}"
|
||||
}
|
||||
}
|
||||
),
|
||||
Connected => unreachable!(),
|
||||
};
|
||||
rsx!(
|
||||
div {
|
||||
class: "{login_box}",
|
||||
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!(
|
||||
"
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
background-color: grey;
|
||||
overflow: auto;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
rsx!(
|
||||
sir::AppStyle { }
|
||||
match *STATE.status.read() {
|
||||
Connected => rsx!(ServerView {}),
|
||||
_ => rsx!(LoginView {}),
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
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 std::net::ToSocketAddrs;
|
||||
use std::{fmt, io, 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::DigitallySignedStruct;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
) -> Result<(), Error> {
|
||||
let config = ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
|
||||
let addr = format!("{}:{}", address, 64738)
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let server_tcp = TcpStream::connect(addr).await?;
|
||||
let server_stream = connector
|
||||
//.connect("127.0.0.1".try_into()?, server_tcp)
|
||||
.connect(address.try_into().map_err(anyhow::Error::from)?, 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);
|
||||
|
||||
super::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;
|
||||
}
|
||||
@@ -1,424 +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 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()?
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||