Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e77b943db | |||
| 9d6c590315 | |||
| e295783fe4 | |||
| e72bb6d4c4 | |||
| 63ce666fa7 | |||
| 3a9bb60605 | |||
| 7f35a216cd | |||
| f0ce15000e | |||
| 7337b3e49b | |||
| d67a19c478 | |||
| 518c50d8a4 | |||
| 847c636f41 | |||
| 9006a082b0 | |||
| 083a11274e | |||
| 2fcb853c30 | |||
| feaa9f2bda | |||
| aa3fcf09cf | |||
| a30082eebe | |||
| 7c75e64a64 | |||
| 65883917b0 | |||
| c8119d0efa | |||
| d7b88874df | |||
| f001a192e1 | |||
| 37c0bce57e | |||
| 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 |
@@ -0,0 +1 @@
|
||||
target
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Build android container
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
|
||||
jobs:
|
||||
android-release-builder-container-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.ohea.xyz
|
||||
username: ${{ secrets.CI_REGISTRY_USER }}
|
||||
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build Android builder image
|
||||
shell: bash
|
||||
run: |
|
||||
docker pull "$(grep -m1 '^FROM' ./docker/android-release-builder.Dockerfile | awk '{print $2}')"
|
||||
docker build -t git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest -f ./docker/android-release-builder.Dockerfile .
|
||||
docker push git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest
|
||||
@@ -0,0 +1,149 @@
|
||||
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.3
|
||||
|
||||
- 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
|
||||
|
||||
macos_build:
|
||||
runs-on: macos
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo
|
||||
./target
|
||||
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
rust-${{ runner.os }}-
|
||||
|
||||
- name: Install cargo binstall
|
||||
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
|
||||
|
||||
- name: Install dioxus-cli
|
||||
run: cargo binstall dioxus-cli --version 0.7.3 --no-confirm
|
||||
|
||||
- name: Build dioxus project
|
||||
run: dx bundle --platform macos --release -p mumble-web2-gui
|
||||
|
||||
- name: Save Rust cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo
|
||||
./target
|
||||
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Upload mumble-web2-gui Artifact
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: mumble-web2-gui-macos-arm64
|
||||
path: gui/dist
|
||||
retention-days: 5
|
||||
|
||||
windows_build:
|
||||
runs-on: windows
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo
|
||||
./target
|
||||
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
rust-${{ runner.os }}-
|
||||
|
||||
- name: Pull builder container
|
||||
run: docker pull git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest
|
||||
|
||||
- name: Bundle dioxus project
|
||||
run: docker run `
|
||||
--mount "type=bind,source=${PWD},target=C:\app" `
|
||||
--workdir "C:\app\gui" `
|
||||
git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest `
|
||||
C:\Users\ContainerAdministrator\.cargo\bin\dx.exe bundle --verbose --trace -p mumble-web2-gui --release --windows
|
||||
|
||||
- name: Save Rust cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo
|
||||
./target
|
||||
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Upload mumble-web2-gui Windows Artifact
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: mumble-web2-gui-windows
|
||||
path: gui/dist
|
||||
retention-days: 5
|
||||
|
||||
android_build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.ohea.xyz/mumble/mumble-web2/android-release-builder:latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build dioxus project (x86_64-linux-android)
|
||||
run: dx build --platform android --target x86_64-linux-android --release -p mumble-web2-gui
|
||||
|
||||
- name: Build dioxus project (aarch64-linux-android)
|
||||
run: dx build --platform android --target aarch64-linux-android --release -p mumble-web2-gui
|
||||
|
||||
- name: Upload mumble-web2-gui Android Artifact
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: mumble-web2-android
|
||||
path: target/dx/mumble-web2-gui/release/android/app/app/build/outputs/apk/debug/app-debug.apk
|
||||
retention-days: 5
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Build Mumble Web 2 release builder containers
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
|
||||
jobs:
|
||||
windows-release-builder-container-build:
|
||||
runs-on: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.ohea.xyz
|
||||
username: ${{ secrets.CI_REGISTRY_USER }}
|
||||
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build Windows image
|
||||
shell: bash
|
||||
run: |
|
||||
docker pull "$(grep -m1 '^FROM' ./docker/windows-release-builder.Dockerfile | awk '{print $2}')"
|
||||
docker build -t git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest -f ./docker/windows-release-builder.Dockerfile .
|
||||
docker push git.ohea.xyz/mumble/mumble-web2/windows-release-builder:latest
|
||||
@@ -1,3 +1,9 @@
|
||||
/target
|
||||
dist/
|
||||
server_hash.txt
|
||||
.aider*
|
||||
**.pem
|
||||
proxy/bundle
|
||||
/config.toml
|
||||
proxy/config.toml
|
||||
*_onnx.tar.gz
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"rust-analyzer.cargo.features": ["desktop","web"],
|
||||
"rust-analyzer.cargo.noDefaultFeatures": false
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [ "common","gui", "proxy"]
|
||||
members = ["client", "common", "gui", "proxy"]
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1.0.214", features = ["derive"] }
|
||||
@@ -11,6 +11,16 @@ mumble-web2-common = { path = "common" }
|
||||
version = "0.5.0"
|
||||
package = "mumble-protocol-2x"
|
||||
default-features = false
|
||||
features = [
|
||||
"asynchronous-codec",
|
||||
]
|
||||
features = ["asynchronous-codec"]
|
||||
|
||||
[profile]
|
||||
|
||||
[profile.wasm-dev]
|
||||
inherits = "dev"
|
||||
opt-level = 1
|
||||
|
||||
[profile.server-dev]
|
||||
inherits = "dev"
|
||||
|
||||
[profile.android-dev]
|
||||
inherits = "dev"
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
# GUI Development
|
||||
|
||||
## Running Desktop
|
||||
1. `cargo install dioxus-cli --version 0.6.0-alpha.4`
|
||||
2. `dx build -p mumble-web2-gui --platform desktop`
|
||||
|
||||
## Running Web
|
||||
1. `cargo install dioxus-cli --version 0.6.0-alpha.4`
|
||||
2. `cargo install cargo install wtransport --example gencert`
|
||||
3. in the proxy directory:
|
||||
1. `cp config.toml.example config.toml`
|
||||
2. run `gencert` and copy the certificate hash into config.toml
|
||||
3. `cargo run -p mumble-web2-proxy` in the background
|
||||
1. `cargo install dioxus-cli --version 0.7.1`
|
||||
2. `dx run -p mumble-web2-gui --platform desktop --release`
|
||||
|
||||
## with `dx serve`
|
||||
4. in the gui directory
|
||||
1. `export 'MUMBLE_WEB2_GUI_CONFIG={"cert_hash": <CERTIFICATE HASH HERE>, "proxy_url": "https://localhost:4433"}'`
|
||||
2. `dx serve -p mumble-web2-gui --platform web`
|
||||
5. connect to `localhost:8080` (most common)
|
||||
## Running Web (development)
|
||||
|
||||
## with `mumble-web2-proxy` only
|
||||
4. in the gui directory:
|
||||
1. `dx build -p mumble-web2-gui --platform web`
|
||||
5. connect to `localhost:4434` (most common)
|
||||
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,146 @@
|
||||
[package]
|
||||
name = "mumble-web2-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Web Dependencies
|
||||
# ================
|
||||
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.82", optional = true }
|
||||
web-sys = { version = "=0.3.82", 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-signals = "0.7.2"
|
||||
manganis = "0.7.2"
|
||||
once_cell = "1.19.0"
|
||||
asynchronous-codec = { workspace = true }
|
||||
futures = "^0.3.30"
|
||||
merge-io = "^0.3.0"
|
||||
mumble-protocol = { workspace = true }
|
||||
serde_json = "1"
|
||||
tokio-util = { version = "^0.7.11", features = ["codec", "compat"] }
|
||||
byteorder = "1.5"
|
||||
ogg = "^0.9.1"
|
||||
ordermap = "^0.5.3"
|
||||
html-purifier = "^0.3.0"
|
||||
markdown = "^0.3.0"
|
||||
futures-channel = "^0.3.30"
|
||||
mumble-web2-common = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
tracing-subscriber = { version = "^0.3.18", features = ["ansi"] }
|
||||
tracing = "^0.1.40"
|
||||
color-eyre = "^0.6.3"
|
||||
crossbeam-queue = "^0.3.11"
|
||||
lol_html = "^2.2.0"
|
||||
base64 = "^0.22"
|
||||
mime_guess = "^2.0.5"
|
||||
async_cell = "^0.2.3"
|
||||
reqwest = { version = "^0.12.22", features = ["json"] }
|
||||
dioxus-asset-resolver = "0.7.2"
|
||||
|
||||
# Denoising
|
||||
# =========
|
||||
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
|
||||
"tract",
|
||||
] }
|
||||
crossbeam = "0.8.4"
|
||||
|
||||
# Android dependencies for requesting permissions
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android-permissions = "0.1.2"
|
||||
jni = "0.21.1"
|
||||
ndk-context = "0.1.1"
|
||||
|
||||
[patch.crates-io]
|
||||
tract-hir = "=0.12.4"
|
||||
tract-core = "=0.12.4"
|
||||
tract-onnx = "=0.12.4"
|
||||
tract-pulse = "=0.12.4"
|
||||
|
||||
[features]
|
||||
web = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"serde-wasm-bindgen",
|
||||
"js-sys",
|
||||
"web-sys",
|
||||
"gloo-timers",
|
||||
"tracing-web",
|
||||
"deep_filter/wasm",
|
||||
]
|
||||
desktop = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing-subscriber/env-filter",
|
||||
"opus",
|
||||
"cpal",
|
||||
"dasp_ring_buffer",
|
||||
"etcetera",
|
||||
"mumble-web2-common/networking",
|
||||
]
|
||||
mobile = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing-subscriber/env-filter",
|
||||
"opus",
|
||||
"cpal",
|
||||
"dasp_ring_buffer",
|
||||
"mumble-web2-common/networking",
|
||||
]
|
||||
@@ -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,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();
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
use dioxus_signals::{ReadableExt as _, Signal};
|
||||
use mime_guess::Mime;
|
||||
use mumble_web2_common::ProxyOverrides;
|
||||
use ordermap::OrderSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
pub type ChannelId = u32;
|
||||
pub type UserId = u32;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConnectionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioSettings {
|
||||
pub denoise: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConnectTarget {
|
||||
Direct { host: String, port: u16 },
|
||||
Proxy(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Command {
|
||||
Connect {
|
||||
target: ConnectTarget,
|
||||
username: String,
|
||||
config: ProxyOverrides,
|
||||
},
|
||||
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,
|
||||
},
|
||||
UpdateAudioSettings(AudioSettings),
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct UserState {
|
||||
pub name: String,
|
||||
pub channel: ChannelId,
|
||||
pub deaf: bool,
|
||||
pub mute: bool,
|
||||
pub suppress: bool,
|
||||
pub self_deaf: bool,
|
||||
pub self_mute: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Chat {
|
||||
pub raw: String,
|
||||
pub dangerous_html: String,
|
||||
pub sender: Option<UserId>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ChannelState {
|
||||
pub name: String,
|
||||
pub children: OrderSet<ChannelId>,
|
||||
pub users: OrderSet<UserId>,
|
||||
pub parent: Option<ChannelId>,
|
||||
pub position: i32,
|
||||
}
|
||||
|
||||
impl ChannelState {
|
||||
pub fn update_from_channel_state(
|
||||
&mut self,
|
||||
channel_state: &mumble_protocol::control::msgs::ChannelState,
|
||||
) {
|
||||
if channel_state.has_position() {
|
||||
self.position = channel_state.get_position();
|
||||
}
|
||||
if channel_state.has_parent() {
|
||||
self.parent = Some(channel_state.get_parent());
|
||||
}
|
||||
if channel_state.has_name() {
|
||||
self.name = channel_state.get_name().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ChannelsState {
|
||||
pub channels: HashMap<ChannelId, ChannelState>,
|
||||
}
|
||||
|
||||
impl ChannelsState {
|
||||
pub fn update_from_channel_state(
|
||||
&mut self,
|
||||
channel_state: &mumble_protocol::control::msgs::ChannelState,
|
||||
) {
|
||||
self.channels
|
||||
.entry(channel_state.get_channel_id())
|
||||
.or_default()
|
||||
.update_from_channel_state(channel_state);
|
||||
|
||||
self.update_channel_parents();
|
||||
}
|
||||
|
||||
pub fn update_from_channel_remove(
|
||||
&mut self,
|
||||
channel_remove: &mumble_protocol::control::msgs::ChannelRemove,
|
||||
) {
|
||||
self.channels.remove(&channel_remove.get_channel_id());
|
||||
self.update_channel_parents();
|
||||
}
|
||||
|
||||
pub fn update_channel_parents(&mut self) {
|
||||
// Zero out existing children
|
||||
for state in self.channels.values_mut() {
|
||||
state.children.clear();
|
||||
}
|
||||
|
||||
let mut to_sort: Vec<(ChannelId, Option<ChannelId>, i32, String)> = Vec::new();
|
||||
for (id, state) in self.channels.iter() {
|
||||
// Handle channels with no parent (the root channel)
|
||||
let Some(parent_id) = state.parent else {
|
||||
to_sort.push((*id, None, 0, state.name.clone()));
|
||||
continue;
|
||||
};
|
||||
|
||||
// If a channel has a parent that we haven't gotten a channel
|
||||
// state packet for, ignore it
|
||||
if !self.channels.contains_key(&parent_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
to_sort.push((*id, Some(parent_id), state.position, state.name.clone()));
|
||||
}
|
||||
|
||||
let pos_name: HashMap<ChannelId, (i32, String)> = self
|
||||
.channels
|
||||
.iter()
|
||||
.map(|(&id, state)| (id, (state.position, state.name.clone())))
|
||||
.collect();
|
||||
|
||||
let mut updated: HashSet<ChannelId> = HashSet::new();
|
||||
|
||||
while updated.len() < to_sort.len() {
|
||||
for &(id, ref parent_id, position, ref name) in &to_sort {
|
||||
let Some(parent_id) = parent_id else {
|
||||
updated.insert(id);
|
||||
continue;
|
||||
};
|
||||
|
||||
if updated.contains(&id) || !updated.contains(&parent_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unwrap should never fail here since we pre filter
|
||||
let parent = self.channels.get_mut(&parent_id).unwrap();
|
||||
|
||||
let mut insert_index = parent.children.len();
|
||||
for (i, &child) in parent.children.iter().enumerate() {
|
||||
let (p, ref n) = pos_name[&child];
|
||||
if (position == p && name < n) || p > position {
|
||||
insert_index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
parent.children.insert_before(insert_index, id);
|
||||
updated.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ServerState {
|
||||
pub channels_state: ChannelsState,
|
||||
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: Signal<ConnectionState>,
|
||||
pub server: Signal<ServerState>,
|
||||
pub audio: Signal<AudioSettings>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for State {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("State")
|
||||
.field("status", &self.status.read())
|
||||
.field("server", &self.server.read())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedState = Arc<State>;
|
||||
@@ -0,0 +1,188 @@
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
|
||||
use df::tract::{DfParams, DfTract, RuntimeParams};
|
||||
use dioxus_asset_resolver::read_asset_bytes;
|
||||
use manganis::{asset, Asset};
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::imp::SpawnHandle;
|
||||
|
||||
static DF_MODEL: Asset = asset!("/assets/DeepFilterNet3_ll_onnx.tar.gz");
|
||||
// TODO: make this user configurable.
|
||||
static DEFAULT_NOISE_FLOOR: f32 = 0.001;
|
||||
// 200ms hold at 48kHz sample rate
|
||||
static HOLD_SAMPLES_MAX: usize = 48000 / 5; // 9600 samples = 200ms
|
||||
|
||||
/// Indicates the transmission state after processing audio.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransmitState {
|
||||
/// Audio is above threshold, or below but within hold period - transmit normally
|
||||
Transmitting,
|
||||
/// Hold period expired - send this frame as terminator (end_bit = true)
|
||||
Terminator,
|
||||
/// Silent and not transmitting - don't send anything
|
||||
Silent,
|
||||
}
|
||||
|
||||
enum DenoisingModelState {
|
||||
Nothing,
|
||||
Downloading(Arc<AtomicCell<Option<DfParams>>>),
|
||||
Availible(Box<DfTract>),
|
||||
}
|
||||
|
||||
fn with_denoising_model<O>(spawn: &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);
|
||||
let model = DF_MODEL.to_string();
|
||||
spawn.spawn(async move {
|
||||
let model_bytes = match read_asset_bytes(&model).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
error!("could not read denoising model from \"{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: SpawnHandle,
|
||||
buffer: Vec<f32>,
|
||||
noise_floor: f32,
|
||||
/// Whether we were transmitting in the previous frame
|
||||
was_transmitting: bool,
|
||||
/// Number of samples we've been below threshold (for hold period)
|
||||
hold_samples: usize,
|
||||
}
|
||||
|
||||
impl AudioProcessor {
|
||||
pub fn new(denoise: bool) -> Self {
|
||||
AudioProcessor {
|
||||
denoise,
|
||||
spawn: SpawnHandle::current(),
|
||||
buffer: Vec::new(),
|
||||
noise_floor: DEFAULT_NOISE_FLOOR,
|
||||
was_transmitting: false,
|
||||
hold_samples: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioProcessor {
|
||||
pub fn process(
|
||||
&mut self,
|
||||
audio: &[f32],
|
||||
channels: usize,
|
||||
output: &mut Vec<f32>,
|
||||
) -> TransmitState {
|
||||
let mut include_raw = true;
|
||||
if self.denoise {
|
||||
with_denoising_model(&self.spawn, |df| {
|
||||
include_raw = false;
|
||||
|
||||
self.buffer.extend(audio.iter().step_by(channels).copied());
|
||||
output.reserve(audio.len());
|
||||
|
||||
let hop = df.hop_size;
|
||||
let mut i = 0;
|
||||
while self.buffer[i..].len() >= hop {
|
||||
let audio = &self.buffer[i..][..hop];
|
||||
i += audio.len();
|
||||
|
||||
let j = output.len();
|
||||
output.extend(std::iter::repeat_n(0f32, audio.len()));
|
||||
let output = &mut output[j..];
|
||||
|
||||
df.process(
|
||||
slice_as_arrayview(audio, &[audio.len()])
|
||||
.into_shape((1, audio.len()))
|
||||
.unwrap(),
|
||||
mut_slice_as_arrayviewmut(output, &[output.len()])
|
||||
.into_shape((1, output.len()))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
self.buffer.splice(..i, []);
|
||||
});
|
||||
}
|
||||
|
||||
if include_raw {
|
||||
output.extend(audio.iter().step_by(channels).copied());
|
||||
}
|
||||
|
||||
// Calculate average amplitude for VAD
|
||||
let avg: f32 = if output.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
output.iter().map(|x| x.abs()).sum::<f32>() / output.len() as f32
|
||||
};
|
||||
|
||||
let above_threshold = avg >= self.noise_floor;
|
||||
let samples_in_frame = output.len();
|
||||
|
||||
let state = if above_threshold {
|
||||
// Above threshold - reset hold counter and transmit
|
||||
self.hold_samples = 0;
|
||||
self.was_transmitting = true;
|
||||
TransmitState::Transmitting
|
||||
} else if self.was_transmitting && self.hold_samples < HOLD_SAMPLES_MAX {
|
||||
// Below threshold but in hold period - keep transmitting
|
||||
self.hold_samples += samples_in_frame;
|
||||
TransmitState::Transmitting
|
||||
} else if self.was_transmitting {
|
||||
// Hold period expired - send terminator
|
||||
self.was_transmitting = false;
|
||||
self.hold_samples = 0;
|
||||
TransmitState::Terminator
|
||||
} else {
|
||||
// Not transmitting and below threshold - stay silent
|
||||
output.clear(); // Don't accumulate stale audio during silence
|
||||
TransmitState::Silent
|
||||
};
|
||||
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
pub type AudioProcessorSender = Arc<AtomicCell<Option<AudioProcessor>>>;
|
||||
@@ -0,0 +1,91 @@
|
||||
use crate::app::Command;
|
||||
use color_eyre::eyre::Error;
|
||||
use dioxus::hooks::UnboundedReceiver;
|
||||
use mumble_web2_common::{ClientConfig, ServerStatus};
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
|
||||
pub struct MobilePlatform;
|
||||
|
||||
impl super::PlatformInterface for MobilePlatform {
|
||||
type AudioSystem = super::native_audio::NativeAudioSystem;
|
||||
|
||||
async fn load_config() -> color_eyre::Result<ClientConfig> {
|
||||
Ok(ClientConfig {
|
||||
proxy_url: None,
|
||||
cert_hash: None,
|
||||
any_server: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_username() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn load_server_url() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn set_default_username(_username: &str) -> Option<()> {
|
||||
None
|
||||
}
|
||||
|
||||
fn set_default_server(server: &str) -> Option<()> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
gui_config: &ClientConfig,
|
||||
) -> Result<(), Error> {
|
||||
super::connect::network_connect(address, username, event_rx, gui_config).await
|
||||
}
|
||||
|
||||
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
||||
super::connect::get_status(client).await
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::filter::EnvFilter;
|
||||
|
||||
let env_filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_env_filter(env_filter)
|
||||
.init();
|
||||
}
|
||||
|
||||
fn request_permissions() {
|
||||
request_recording_permission();
|
||||
}
|
||||
|
||||
async fn sleep(duration: Duration) {
|
||||
tokio::time::sleep(duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn request_recording_permission() {}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn request_recording_permission() {
|
||||
use android_permissions::{PermissionManager, RECORD_AUDIO};
|
||||
use jni::{objects::JObject, JavaVM};
|
||||
|
||||
let ctx = ndk_context::android_context();
|
||||
let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() };
|
||||
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
|
||||
|
||||
let manager = PermissionManager::create(vm, activity).unwrap();
|
||||
if !manager.check(&RECORD_AUDIO).unwrap() {
|
||||
manager.request(&[&RECORD_AUDIO]).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
use crate::app::Command;
|
||||
use color_eyre::eyre::Error;
|
||||
use dioxus::hooks::{UnboundedReceiver, UnboundedSender};
|
||||
use futures::io::{AsyncRead, AsyncWrite};
|
||||
use mumble_protocol::control::{ClientControlCodec, ControlPacket};
|
||||
use mumble_protocol::Serverbound;
|
||||
use mumble_web2_common::GuiConfig;
|
||||
use crate::app::{Command, ConnectTarget, SharedState};
|
||||
use color_eyre::eyre::{bail, Error};
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use mumble_protocol::control::ClientControlCodec;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::{fmt, io, sync::Arc};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::rustls;
|
||||
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
|
||||
@@ -15,37 +12,9 @@ use tokio_rustls::rustls::ClientConfig;
|
||||
use tokio_rustls::rustls::DigitallySignedStruct;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
|
||||
use tracing::{info, instrument};
|
||||
|
||||
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();
|
||||
|
||||
impl AudioSystem {
|
||||
pub fn new(sender: UnboundedSender<ControlPacket<Serverbound>>) -> Result<Self, Error> {
|
||||
// TODO
|
||||
Ok(AudioSystem())
|
||||
}
|
||||
|
||||
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
|
||||
// TODO
|
||||
Ok(AudioPlayer())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioPlayer();
|
||||
|
||||
impl AudioPlayer {
|
||||
pub fn play_opus(&mut self, payload: &[u8]) {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
use mumble_web2_common::ProxyOverrides;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NoCertificateVerification;
|
||||
@@ -99,11 +68,23 @@ impl ServerCertVerifier for NoCertificateVerification {
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
pub async fn network_connect(
|
||||
address: String,
|
||||
target: ConnectTarget,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
) -> Result<(), Error> {
|
||||
info!("connecting");
|
||||
|
||||
let (host, port) = match target {
|
||||
ConnectTarget::Direct { host, port } => (host, port),
|
||||
ConnectTarget::Proxy(_) => {
|
||||
bail!("desktop/mobile platform requires a direct host:port, not a proxy URL")
|
||||
}
|
||||
};
|
||||
|
||||
let config = ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
||||
@@ -111,15 +92,14 @@ pub async fn network_connect(
|
||||
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
|
||||
let addr = format!("{}:{}", address, 64738)
|
||||
let addr = (&*host, port)
|
||||
.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)
|
||||
.connect(host.try_into()?, server_tcp)
|
||||
.await?;
|
||||
let (read_server, write_server) = tokio::io::split(server_stream);
|
||||
|
||||
@@ -129,32 +109,12 @@ pub async fn network_connect(
|
||||
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
|
||||
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
|
||||
|
||||
crate::network_loop(username, event_rx, reader, writer).await
|
||||
let (outgoing_send, outgoing_recv) = futures_channel::mpsc::unbounded();
|
||||
spawn(crate::sender_loop(outgoing_recv, writer));
|
||||
crate::network_loop(username, state, event_rx, outgoing_send, reader).await
|
||||
}
|
||||
|
||||
pub fn set_default_username(username: &str) -> Option<()> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn load_username() -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
|
||||
pub fn load_config() -> Option<GuiConfig> {
|
||||
None
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
#[allow(unused)]
|
||||
pub use tokio::spawn;
|
||||
#[allow(unused)]
|
||||
pub type SpawnHandle = tokio::runtime::Handle;
|
||||
@@ -0,0 +1,65 @@
|
||||
use crate::app::{Command, ConnectTarget, SharedState};
|
||||
use color_eyre::eyre::Error;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Desktop platform implementation using Tokio and native audio.
|
||||
pub struct DesktopPlatform;
|
||||
|
||||
impl super::PlatformInterface for DesktopPlatform {
|
||||
type AudioSystem = super::native_audio::NativeAudioSystem;
|
||||
type ConfigSystem = super::native_config::NativeConfigSystem;
|
||||
|
||||
async fn sleep(duration: Duration) {
|
||||
tokio::time::sleep(duration).await;
|
||||
}
|
||||
|
||||
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
|
||||
Ok(ProxyOverrides {
|
||||
proxy_url: None,
|
||||
cert_hash: None,
|
||||
any_server: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn network_connect(
|
||||
target: ConnectTarget,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
) -> Result<(), Error> {
|
||||
super::connect::network_connect(target, username, event_rx, overrides, state).await
|
||||
}
|
||||
|
||||
async fn get_status(
|
||||
_client: &reqwest::Client,
|
||||
address: &str,
|
||||
) -> color_eyre::Result<ServerStatus> {
|
||||
let (host, port) = match address.rsplit_once(':') {
|
||||
Some((h, p)) => (h, p.parse().unwrap_or(64738)),
|
||||
None => (address, 64738),
|
||||
};
|
||||
mumble_web2_common::ping_server(host, port).await
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::filter::EnvFilter;
|
||||
|
||||
let env_filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_env_filter(env_filter)
|
||||
.init();
|
||||
}
|
||||
|
||||
fn request_permissions() {
|
||||
// No-op on desktop
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use crate::app::{Command, ConnectTarget, SharedState};
|
||||
use color_eyre::eyre::Error;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
|
||||
pub struct MobilePlatform;
|
||||
|
||||
impl super::PlatformInterface for MobilePlatform {
|
||||
type AudioSystem = super::native_audio::NativeAudioSystem;
|
||||
type ConfigSystem = super::native_config::NativeConfigSystem;
|
||||
|
||||
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
|
||||
Ok(ProxyOverrides {
|
||||
proxy_url: None,
|
||||
cert_hash: None,
|
||||
any_server: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn network_connect(
|
||||
target: ConnectTarget,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
) -> Result<(), Error> {
|
||||
super::connect::network_connect(target, username, event_rx, overrides, state).await
|
||||
}
|
||||
|
||||
async fn get_status(
|
||||
_client: &reqwest::Client,
|
||||
address: &str,
|
||||
) -> color_eyre::Result<ServerStatus> {
|
||||
let (host, port) = match address.rsplit_once(':') {
|
||||
Some((h, p)) => (h, p.parse().unwrap_or(64738)),
|
||||
None => (address, 64738),
|
||||
};
|
||||
mumble_web2_common::ping_server(host, port).await
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::filter::EnvFilter;
|
||||
|
||||
let env_filter = EnvFilter::builder()
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(true)
|
||||
.with_level(true)
|
||||
.with_env_filter(env_filter)
|
||||
.init();
|
||||
}
|
||||
|
||||
fn request_permissions() {
|
||||
request_recording_permission();
|
||||
}
|
||||
|
||||
async fn sleep(duration: Duration) {
|
||||
tokio::time::sleep(duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn request_recording_permission() {}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn request_recording_permission() {
|
||||
use android_permissions::{PermissionManager, RECORD_AUDIO};
|
||||
use jni::{objects::JObject, JavaVM};
|
||||
|
||||
let ctx = ndk_context::android_context();
|
||||
let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() };
|
||||
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
|
||||
|
||||
let manager = PermissionManager::create(vm, activity).unwrap();
|
||||
if !manager.check(&RECORD_AUDIO).unwrap() {
|
||||
manager.request(&[&RECORD_AUDIO]).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Platform abstraction layer
|
||||
//!
|
||||
//! This module defines traits that each platform (web, desktop, mobile) must implement.
|
||||
//! The traits make the platform boundary explicit and provide compile-time verification.
|
||||
#![allow(async_fn_in_trait)]
|
||||
|
||||
use crate::app::{Command, ConnectTarget, SharedState};
|
||||
use crate::effects::AudioProcessor;
|
||||
use color_eyre::eyre::Error;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
// ============================================================================
|
||||
// Trait Definitions
|
||||
// ============================================================================
|
||||
|
||||
/// Platform-specific audio subsystem for capturing microphone input and creating playback streams.
|
||||
///
|
||||
/// The audio system handles Opus encoding internally - callers receive encoded frames
|
||||
/// ready for network transmission.
|
||||
pub trait AudioSystemInterface: Sized {
|
||||
/// The player type returned by [`create_player`](Self::create_player).
|
||||
type AudioPlayer: AudioPlayerInterface;
|
||||
|
||||
/// Initialize the audio system.
|
||||
async fn new() -> Result<Self, Error>;
|
||||
|
||||
/// Set the processor for the microphone input, mainly noise cancellation settings.
|
||||
fn set_processor(&self, processor: AudioProcessor);
|
||||
|
||||
/// Begin listening to microphone input, calling the `each` function with
|
||||
/// encoded opus frames.
|
||||
fn start_recording(
|
||||
&mut self,
|
||||
each: impl FnMut(Vec<u8>, bool) + Send + 'static,
|
||||
) -> Result<(), Error>;
|
||||
|
||||
/// Begin playback of an audio stream, returning an object that can be passed opus frames.
|
||||
fn create_player(&mut self) -> Result<Self::AudioPlayer, Error>;
|
||||
}
|
||||
|
||||
/// A handle to an active audio playback stream for a single remote user.
|
||||
///
|
||||
/// Each connected user gets their own `AudioPlayer` instance, which decodes
|
||||
/// incoming Opus frames and outputs PCM audio to the platform's audio device.
|
||||
/// The player manages its own decoder state and output buffer.
|
||||
pub trait AudioPlayerInterface {
|
||||
/// Decode and play an Opus-encoded audio frame.
|
||||
fn play_opus(&mut self, payload: &[u8]);
|
||||
}
|
||||
|
||||
pub trait ConfigSystemInterface: Sized + Clone {
|
||||
fn new() -> Result<Self, Error>;
|
||||
|
||||
fn config_get<T>(&self, key: &str) -> Option<T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned;
|
||||
|
||||
fn config_set<T>(&self, key: &str, value: &T)
|
||||
where
|
||||
T: serde::Serialize;
|
||||
}
|
||||
|
||||
/// This is the main trait that each platform must implement. It combines all
|
||||
/// platform-specific functionality into a single interface, providing compile-time
|
||||
/// verification that all platforms implement the required functionality.
|
||||
pub trait PlatformInterface {
|
||||
type AudioSystem: AudioSystemInterface;
|
||||
type ConfigSystem: ConfigSystemInterface;
|
||||
|
||||
/// Initialize logging for the platform.
|
||||
fn init_logging();
|
||||
|
||||
/// Request runtime permissions (Android audio recording, etc.).
|
||||
fn request_permissions();
|
||||
|
||||
/// Establish a connection to the Mumble server and run the network loop.
|
||||
fn network_connect(
|
||||
target: ConnectTarget,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
proxy_overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
) -> impl Future<Output = Result<(), Error>>;
|
||||
|
||||
/// Get server status (user count, version, etc.) for the given address.
|
||||
///
|
||||
/// On web, this goes through the proxy's /status endpoint and ignores `address`
|
||||
/// (the proxy is bound to a specific server). On desktop/mobile, this pings the
|
||||
/// given address directly via UDP.
|
||||
fn get_status(
|
||||
client: &reqwest::Client,
|
||||
address: &str,
|
||||
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
|
||||
|
||||
/// Load the proxy overrides (proxy URL, cert hash, etc.).
|
||||
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>>;
|
||||
|
||||
/// Async sleep for the given duration.
|
||||
fn sleep(duration: Duration) -> impl Future<Output = ()>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Platform Modules
|
||||
// ============================================================================
|
||||
|
||||
mod stub;
|
||||
|
||||
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
||||
mod connect;
|
||||
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
||||
mod native_audio;
|
||||
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
||||
mod native_config;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
mod desktop;
|
||||
|
||||
#[cfg(feature = "mobile")]
|
||||
mod mobile;
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
mod web;
|
||||
|
||||
// ============================================================================
|
||||
// Platform Type Alias
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
pub type Platform = web::WebPlatform;
|
||||
|
||||
#[cfg(all(feature = "desktop", not(feature = "web")))]
|
||||
pub type Platform = desktop::DesktopPlatform;
|
||||
|
||||
#[cfg(all(feature = "mobile", not(feature = "web"), not(feature = "desktop")))]
|
||||
pub type Platform = mobile::MobilePlatform;
|
||||
|
||||
#[cfg(all(
|
||||
not(feature = "mobile"),
|
||||
not(feature = "web"),
|
||||
not(feature = "desktop")
|
||||
))]
|
||||
pub type Platform = stub::StubPlatform;
|
||||
|
||||
pub type AudioSystem = <Platform as PlatformInterface>::AudioSystem;
|
||||
pub type AudioPlayer = <AudioSystem as AudioSystemInterface>::AudioPlayer;
|
||||
|
||||
pub type ConfigSystem = <Platform as PlatformInterface>::ConfigSystem;
|
||||
|
||||
// ========================
|
||||
// Platform Async Runtime
|
||||
// ========================
|
||||
|
||||
// Note: these can not be part of the Platform because they differ in Send requiremets
|
||||
#[cfg(all(any(feature = "desktop", feature = "mobile"), not(feature = "web")))]
|
||||
pub use connect::{spawn, SpawnHandle};
|
||||
#[cfg(all(
|
||||
not(feature = "desktop"),
|
||||
not(feature = "mobile"),
|
||||
not(feature = "web")
|
||||
))]
|
||||
pub use stub::{spawn, SpawnHandle};
|
||||
#[cfg(feature = "web")]
|
||||
pub use web::{spawn, SpawnHandle};
|
||||
|
||||
// =======================
|
||||
// Compile-time Assertions
|
||||
// =======================
|
||||
const _: () = {
|
||||
fn assert_platform<T: PlatformInterface>() {}
|
||||
|
||||
// Check each implementation, and prevent warnings that the implementations are unused.
|
||||
#[cfg(feature = "web")]
|
||||
let _ = assert_platform::<web::WebPlatform>;
|
||||
#[cfg(feature = "desktop")]
|
||||
let _ = assert_platform::<desktop::DesktopPlatform>;
|
||||
#[cfg(feature = "mobile")]
|
||||
let _ = assert_platform::<mobile::MobilePlatform>;
|
||||
let _ = assert_platform::<stub::StubPlatform>;
|
||||
};
|
||||
|
||||
fn global_default_config() -> HashMap<String, serde_json::Value> {
|
||||
serde_json::json!({})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
|
||||
use color_eyre::eyre::{eyre, Error};
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _};
|
||||
use std::mem::replace;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub struct NativeAudioSystem {
|
||||
output: cpal::Device,
|
||||
input: cpal::Device,
|
||||
processors: AudioProcessorSender,
|
||||
recording_stream: Option<cpal::Stream>,
|
||||
}
|
||||
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
const PACKET_SAMPLES: u32 = 960;
|
||||
// Divide by 1000 to get samples per ms, then multiply by 60ms for max Opus frame size.
|
||||
const MAX_DECODE_SAMPLES: usize = SAMPLE_RATE as usize / 1000 * 60;
|
||||
|
||||
fn encode_and_send(
|
||||
state: TransmitState,
|
||||
output_buffer: &mut Vec<f32>,
|
||||
encoder: &mut opus::Encoder,
|
||||
each: &mut impl FnMut(Vec<u8>, bool),
|
||||
) {
|
||||
let (is_terminator, should_encode) = match state {
|
||||
TransmitState::Silent => return,
|
||||
TransmitState::Transmitting => (false, output_buffer.len() >= PACKET_SAMPLES as usize),
|
||||
TransmitState::Terminator => {
|
||||
output_buffer.resize(PACKET_SAMPLES as usize, 0.0);
|
||||
(true, true)
|
||||
}
|
||||
};
|
||||
|
||||
if should_encode {
|
||||
let remainder = output_buffer.split_off(PACKET_SAMPLES as usize);
|
||||
let frame = replace(output_buffer, remainder);
|
||||
match encoder.encode_vec_float(&frame, frame.len() * 2) {
|
||||
Ok(encoded) => each(encoded, is_terminator),
|
||||
Err(e) => error!("error encoding {} samples: {e:?}", frame.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Buffer = Arc<Mutex<dasp_ring_buffer::Bounded<Vec<i16>>>>;
|
||||
|
||||
impl NativeAudioSystem {
|
||||
fn choose_config(
|
||||
&self,
|
||||
configs: impl Iterator<Item = cpal::SupportedStreamConfigRange>,
|
||||
) -> Result<cpal::StreamConfig, Error> {
|
||||
let mut supported_configs: Vec<_> = configs
|
||||
.filter_map(|cfg| cfg.try_with_sample_rate(cpal::SampleRate(SAMPLE_RATE)))
|
||||
.filter(|cfg| cfg.sample_format() == cpal::SampleFormat::I16)
|
||||
.map(|cfg| cpal::StreamConfig {
|
||||
buffer_size: cpal::BufferSize::Fixed(match *cfg.buffer_size() {
|
||||
cpal::SupportedBufferSize::Range { min, max } => 480.clamp(min, max),
|
||||
cpal::SupportedBufferSize::Unknown => 480,
|
||||
}),
|
||||
..cfg.config()
|
||||
})
|
||||
.collect();
|
||||
supported_configs.sort_by(|a, b| {
|
||||
let cpal::BufferSize::Fixed(a_buf) = a.buffer_size else {
|
||||
unreachable!()
|
||||
};
|
||||
let cpal::BufferSize::Fixed(b_buf) = b.buffer_size else {
|
||||
unreachable!()
|
||||
};
|
||||
Ord::cmp(&a.channels, &b.channels).then(Ord::cmp(&a_buf, &b_buf))
|
||||
});
|
||||
supported_configs
|
||||
.get(0)
|
||||
.cloned()
|
||||
.ok_or(eyre!("no supported stream configs"))
|
||||
}
|
||||
}
|
||||
|
||||
impl super::AudioSystemInterface for NativeAudioSystem {
|
||||
type AudioPlayer = NativeAudioPlayer;
|
||||
|
||||
async fn new() -> Result<Self, Error> {
|
||||
let host = cpal::default_host();
|
||||
let name = host.id();
|
||||
let processors = AudioProcessorSender::default();
|
||||
Ok(NativeAudioSystem {
|
||||
output: host
|
||||
.default_output_device()
|
||||
.ok_or(eyre!("no output devices from {name:?}"))?,
|
||||
input: host
|
||||
.default_input_device()
|
||||
.ok_or(eyre!("no input devices from {name:?}"))?,
|
||||
processors,
|
||||
recording_stream: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_processor(&self, processor: AudioProcessor) {
|
||||
self.processors.store(Some(processor))
|
||||
}
|
||||
|
||||
fn start_recording(
|
||||
&mut self,
|
||||
mut each: impl FnMut(Vec<u8>, bool) + Send + 'static,
|
||||
) -> Result<(), Error> {
|
||||
let config = self.choose_config(self.input.supported_input_configs()?)?;
|
||||
info!(
|
||||
"creating recording on {:?} with {:#?}",
|
||||
self.input.name()?,
|
||||
config
|
||||
);
|
||||
let mut encoder =
|
||||
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
|
||||
let mut current_processor = AudioProcessor::new(false);
|
||||
let mut output_buffer = Vec::new();
|
||||
let processors = self.processors.clone();
|
||||
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
|
||||
let data_callback = move |frame: &[f32], _: &cpal::InputCallbackInfo| {
|
||||
if let Some(new_processor) = processors.take() {
|
||||
current_processor = new_processor;
|
||||
}
|
||||
let state =
|
||||
current_processor.process(frame, config.channels as usize, &mut output_buffer);
|
||||
encode_and_send(state, &mut output_buffer, &mut encoder, &mut each);
|
||||
};
|
||||
|
||||
match self
|
||||
.input
|
||||
.build_input_stream(&config, data_callback, error_callback, None)
|
||||
{
|
||||
Ok(stream) => {
|
||||
stream.play()?;
|
||||
self.recording_stream = Some(stream);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
self.recording_stream = None;
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_player(&mut self) -> Result<NativeAudioPlayer, Error> {
|
||||
let config = self.choose_config(self.output.supported_output_configs()?)?;
|
||||
info!(
|
||||
"creating player on {:?} with {:#?}",
|
||||
self.output.name().ok(),
|
||||
&config
|
||||
);
|
||||
let buffer = Arc::new(Mutex::new(dasp_ring_buffer::Bounded::from_raw_parts(
|
||||
0,
|
||||
0,
|
||||
vec![
|
||||
0;
|
||||
SAMPLE_RATE as usize/4 // 250ms of buffer
|
||||
],
|
||||
)));
|
||||
let decoder = opus::Decoder::new(SAMPLE_RATE, opus::Channels::Mono)?;
|
||||
let stream = {
|
||||
let buffer = buffer.clone();
|
||||
self.output.build_output_stream(
|
||||
&config,
|
||||
move |frame, _info| {
|
||||
let mut buffer = buffer.lock().unwrap();
|
||||
for x in frame.chunks_mut(config.channels as usize) {
|
||||
match buffer.pop() {
|
||||
Some(y) => {
|
||||
x.fill(y);
|
||||
}
|
||||
None => {
|
||||
x.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
move |err| error!("could not create output stream {err:?}"),
|
||||
None,
|
||||
)?
|
||||
};
|
||||
stream.play()?;
|
||||
Ok(NativeAudioPlayer {
|
||||
decoder,
|
||||
stream,
|
||||
buffer,
|
||||
tmp: vec![0; MAX_DECODE_SAMPLES],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NativeAudioPlayer {
|
||||
decoder: opus::Decoder,
|
||||
stream: cpal::Stream,
|
||||
buffer: Buffer,
|
||||
tmp: Vec<i16>,
|
||||
}
|
||||
|
||||
impl super::AudioPlayerInterface for NativeAudioPlayer {
|
||||
fn play_opus(&mut self, payload: &[u8]) {
|
||||
let len = match self.decoder.decode(payload, &mut self.tmp, false) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
error!("opus decode error {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut buffer = self.buffer.lock().unwrap();
|
||||
let mut overrun = 0;
|
||||
for x in &self.tmp[..len] {
|
||||
if let Some(_) = buffer.push(*x) {
|
||||
overrun += 1;
|
||||
}
|
||||
}
|
||||
if overrun > 0 {
|
||||
warn!("playback overrun by {overrun} samples");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use color_eyre::eyre::Error;
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct NativeConfigSystem {
|
||||
config_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl super::ConfigSystemInterface for NativeConfigSystem {
|
||||
fn new() -> color_eyre::Result<Self, Error> {
|
||||
return Ok(NativeConfigSystem {
|
||||
config_path: get_config_path()?,
|
||||
});
|
||||
}
|
||||
|
||||
fn config_get<T>(&self, key: &str) -> Option<T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
let config = load_config_map(&self.config_path);
|
||||
|
||||
let Some(value_untyped) = config.get(key).cloned().or_else(|| config_get_default(key))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
match serde_json::from_value::<T>(value_untyped) {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => {
|
||||
let default_value = config_get_default(key)?;
|
||||
serde_json::from_value::<T>(default_value).ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn config_set<T>(&self, key: &str, value: &T)
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let mut config = load_config_map(&self.config_path);
|
||||
let json_value = serde_json::to_value(value).expect("failed to serialize config value");
|
||||
config.insert(key.to_string(), json_value);
|
||||
save_config_map(&config).expect("failed to set config")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "desktop"))]
|
||||
fn get_config_path() -> color_eyre::Result<std::path::PathBuf> {
|
||||
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
|
||||
|
||||
let strategy = choose_app_strategy(AppStrategyArgs {
|
||||
top_level_domain: "xyz".to_string(),
|
||||
author: "ohea".to_string(),
|
||||
app_name: "Mumble Web2".to_string(),
|
||||
})
|
||||
.expect("failed to choose app strategy");
|
||||
Ok(strategy.config_dir().join("config.json"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn get_config_path() -> color_eyre::Result<std::path::PathBuf> {
|
||||
let ctx = ndk_context::android_context();
|
||||
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }?;
|
||||
let mut env = vm.attach_current_thread()?;
|
||||
let ctx = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
|
||||
let cache_dir = env
|
||||
.call_method(ctx, "getFilesDir", "()Ljava/io/File;", &[])?
|
||||
.l()?;
|
||||
let cache_dir: jni::objects::JString = env
|
||||
.call_method(&cache_dir, "toString", "()Ljava/lang/String;", &[])?
|
||||
.l()?
|
||||
.try_into()?;
|
||||
let cache_dir = env.get_string(&cache_dir)?;
|
||||
let cache_dir = cache_dir.to_str()?;
|
||||
Ok(std::path::PathBuf::from(cache_dir).join("config.json"))
|
||||
}
|
||||
|
||||
fn load_config_map(config_path: &std::path::PathBuf) -> HashMap<String, serde_json::Value> {
|
||||
match std::fs::read_to_string(config_path) {
|
||||
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_config_map(config: &HashMap<String, serde_json::Value>) -> color_eyre::Result<()> {
|
||||
let config_path = get_config_path().expect("Could not get config file path.");
|
||||
if let Some(parent) = config_path.parent() {
|
||||
info!("Creating config directory: {}", parent.display());
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let contents = serde_json::to_string_pretty(config)?;
|
||||
info!("Writing config to {}", config_path.display());
|
||||
std::fs::write(&config_path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_get_default(key: &str) -> Option<serde_json::Value> {
|
||||
let default_config = platform_default_config();
|
||||
default_config
|
||||
.get(key)
|
||||
.cloned()
|
||||
.or(super::global_default_config().get(key).cloned())
|
||||
}
|
||||
|
||||
fn platform_default_config() -> HashMap<String, serde_json::Value> {
|
||||
serde_json::json!({})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/// Stub implementation of the platform interface, so that we can
|
||||
/// `cargo check` without any --feature flags.
|
||||
use crate::{
|
||||
app::{ConnectTarget, SharedState},
|
||||
effects::AudioProcessor,
|
||||
};
|
||||
use color_eyre::eyre::Error;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||
use std::future::Future;
|
||||
|
||||
pub struct StubPlatform;
|
||||
|
||||
impl super::PlatformInterface for StubPlatform {
|
||||
type AudioSystem = StubAudioSystem;
|
||||
type ConfigSystem = StubConfigSystem;
|
||||
|
||||
fn init_logging() {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn request_permissions() {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn network_connect(
|
||||
_target: ConnectTarget,
|
||||
_username: String,
|
||||
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
|
||||
_overrides: &ProxyOverrides,
|
||||
_state: SharedState,
|
||||
) -> impl Future<Output = Result<(), Error>> {
|
||||
async { panic!("stubbed platform") }
|
||||
}
|
||||
|
||||
fn get_status(
|
||||
_client: &reqwest::Client,
|
||||
_address: &str,
|
||||
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
|
||||
async { panic!("stubbed platform") }
|
||||
}
|
||||
|
||||
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>> {
|
||||
async { panic!("stubbed platform") }
|
||||
}
|
||||
|
||||
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
|
||||
async { panic!("stubbed platform") }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StubAudioSystem;
|
||||
|
||||
impl super::AudioSystemInterface for StubAudioSystem {
|
||||
type AudioPlayer = StubAudioPlayer;
|
||||
|
||||
async fn new() -> Result<Self, Error> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn set_processor(&self, _processor: AudioProcessor) {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn start_recording(
|
||||
&mut self,
|
||||
_each: impl FnMut(Vec<u8>, bool) + Send + 'static,
|
||||
) -> Result<(), Error> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn create_player(&mut self) -> Result<Self::AudioPlayer, Error> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StubAudioPlayer;
|
||||
|
||||
impl super::AudioPlayerInterface for StubAudioPlayer {
|
||||
fn play_opus(&mut self, _payload: &[u8]) {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StubConfigSystem;
|
||||
|
||||
impl super::ConfigSystemInterface for StubConfigSystem {
|
||||
fn new() -> Result<Self, Error> {
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn config_get<T>(&self, key: &str) -> Option<T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
fn config_set<T>(&self, key: &str, value: &T)
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub struct SpawnHandle;
|
||||
|
||||
impl SpawnHandle {
|
||||
#[allow(unused)]
|
||||
pub fn spawn<F>(&self, _future: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn current() -> Self {
|
||||
SpawnHandle
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn spawn<F>(_future: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
panic!("stubbed platform")
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
use crate::app::{Command, ConnectTarget, SharedState};
|
||||
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
|
||||
use color_eyre::eyre::{bail, eyre, Error};
|
||||
use crossbeam::atomic::AtomicCell;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use js_sys::Float32Array;
|
||||
use manganis::asset;
|
||||
use mumble_protocol::control::ClientControlCodec;
|
||||
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||
use reqwest::Url;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
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::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};
|
||||
|
||||
#[allow(unused)]
|
||||
pub use wasm_bindgen_futures::spawn_local as spawn;
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Clone)]
|
||||
pub struct SpawnHandle;
|
||||
|
||||
impl SpawnHandle {
|
||||
pub fn spawn<F>(&self, future: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(future);
|
||||
}
|
||||
|
||||
pub fn current() -> Self {
|
||||
SpawnHandle
|
||||
}
|
||||
}
|
||||
|
||||
/// Web platform implementation using WebTransport and Web Audio API.
|
||||
pub struct WebPlatform;
|
||||
|
||||
impl super::PlatformInterface for WebPlatform {
|
||||
type AudioSystem = WebAudioSystem;
|
||||
type ConfigSystem = WebConfigSystem;
|
||||
|
||||
fn init_logging() {
|
||||
// copied from tracing_web example usage
|
||||
|
||||
use tracing_subscriber::fmt::format::Pretty;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_web::{performance_layer, MakeWebConsoleWriter};
|
||||
|
||||
let fmt_layer = tracing_subscriber::fmt::layer()
|
||||
.with_ansi(false) // Only partially supported across browsers
|
||||
.without_time() // std::time is not available in browsers
|
||||
.with_writer(MakeWebConsoleWriter::new()) // write events to the console
|
||||
.with_filter(LevelFilter::DEBUG);
|
||||
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt_layer)
|
||||
.with(perf_layer)
|
||||
.init();
|
||||
|
||||
info!("logging initiated");
|
||||
}
|
||||
|
||||
fn request_permissions() {
|
||||
// No-op on web
|
||||
}
|
||||
|
||||
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
|
||||
let overrides = match option_env!("MUMBLE_WEB2_PROXY_OVERRIDES_URL") {
|
||||
Some(url) => Url::parse(url)?,
|
||||
None => absolute_url("overrides")?,
|
||||
};
|
||||
info!("loading config from {}", overrides);
|
||||
|
||||
let config = reqwest::get(overrides)
|
||||
.await?
|
||||
.json::<ProxyOverrides>()
|
||||
.await?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn network_connect(
|
||||
target: ConnectTarget,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
) -> Result<(), Error> {
|
||||
let url = match target {
|
||||
ConnectTarget::Proxy(url) => url,
|
||||
ConnectTarget::Direct { .. } => {
|
||||
bail!("web platform requires a proxy URL, not a direct host:port")
|
||||
}
|
||||
};
|
||||
network_connect(url, username, event_rx, overrides, state).await
|
||||
}
|
||||
|
||||
async fn get_status(
|
||||
client: &reqwest::Client,
|
||||
_address: &str,
|
||||
) -> color_eyre::Result<ServerStatus> {
|
||||
Ok(client
|
||||
.get(absolute_url("status")?)
|
||||
.send()
|
||||
.await?
|
||||
.json::<ServerStatus>()
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn sleep(duration: Duration) {
|
||||
TimeoutFuture::new(duration.as_millis() as u32).await;
|
||||
}
|
||||
}
|
||||
|
||||
trait ResultExt<T> {
|
||||
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 WebAudioSystem {
|
||||
webctx: AudioContext,
|
||||
processors: AudioProcessorSender,
|
||||
}
|
||||
|
||||
async fn attach_worklet(audio_context: &AudioContext, worklet_url: &str) -> 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()?;
|
||||
|
||||
info!("loading mic worklet from {worklet_url:?}");
|
||||
audio_context
|
||||
.audio_worklet()
|
||||
.ey()?
|
||||
.add_module_with_options(worklet_url, &options)
|
||||
.ey()?
|
||||
.into_future()
|
||||
.await
|
||||
.ey()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl super::AudioSystemInterface for WebAudioSystem {
|
||||
type AudioPlayer = WebAudioPlayer;
|
||||
|
||||
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,
|
||||
&asset!("/assets/rust_audio_worklet.js").to_string(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let processors = AudioProcessorSender::default();
|
||||
|
||||
Ok(WebAudioSystem { webctx, processors })
|
||||
}
|
||||
|
||||
fn set_processor(&self, processor: AudioProcessor) {
|
||||
self.processors.store(Some(processor))
|
||||
}
|
||||
|
||||
fn start_recording(&mut self, each: impl FnMut(Vec<u8>, bool) + 'static) -> Result<(), Error> {
|
||||
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(())
|
||||
}
|
||||
|
||||
fn create_player(&mut self) -> Result<WebAudioPlayer, 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(WebAudioPlayer(audio_decoder))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebAudioPlayer(AudioDecoder);
|
||||
|
||||
impl super::AudioPlayerInterface for WebAudioPlayer {
|
||||
fn play_opus(&mut self, payload: &[u8]) {
|
||||
let js_audio_payload = Uint8Array::from(payload);
|
||||
let _ = self.0.decode(
|
||||
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
|
||||
&js_audio_payload.into(),
|
||||
0.0,
|
||||
EncodedAudioChunkType::Key,
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Borrowed from
|
||||
// https://github.com/security-union/videocall-rs/blob/main/videocall-client/src/decode/config.rs#L6
|
||||
fn configure_audio_context() -> AudioContext {
|
||||
let audio_context_options = AudioContextOptions::new();
|
||||
audio_context_options.set_sample_rate(48000 as f32);
|
||||
let audio_context = AudioContext::new_with_context_options(&audio_context_options).unwrap();
|
||||
audio_context
|
||||
}
|
||||
|
||||
trait PromiseExt {
|
||||
fn into_future(self) -> JsFuture;
|
||||
}
|
||||
|
||||
impl PromiseExt for Promise {
|
||||
fn into_future(self) -> JsFuture {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn process_audio(frame: &JsValue, processor: &mut AudioProcessor) -> TransmitState {
|
||||
let Ok(samples) = Reflect::get(&frame, &"data".into()) else {
|
||||
return TransmitState::Silent;
|
||||
};
|
||||
let Ok(samples) = samples.dyn_into::<Float32Array>() else {
|
||||
return TransmitState::Silent;
|
||||
};
|
||||
let input = samples.to_vec();
|
||||
let mut output = Vec::with_capacity(input.len());
|
||||
let state = processor.process(&input, 1, &mut output);
|
||||
if !output.is_empty() {
|
||||
samples.copy_from(&output);
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
async fn run_encoder_worklet(
|
||||
audio_context: &AudioContext,
|
||||
mut each: impl FnMut(Vec<u8>, bool) + 'static,
|
||||
processors: AudioProcessorSender,
|
||||
) -> Result<AudioWorkletNode, Error> {
|
||||
let constraints = MediaStreamConstraints::new();
|
||||
constraints.set_audio(&JsValue::TRUE);
|
||||
let stream = window()
|
||||
.unwrap()
|
||||
.navigator()
|
||||
.media_devices()
|
||||
.ey()?
|
||||
.get_user_media_with_constraints(&constraints)
|
||||
.ey()?
|
||||
.into_future()
|
||||
.await
|
||||
.ey()?
|
||||
.dyn_into()
|
||||
.map_err(|e| JsError::new(&format!("not a stream: {e:?}")))
|
||||
.ey()?;
|
||||
|
||||
let source = audio_context.create_media_stream_source(&stream).ey()?;
|
||||
let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet").ey()?;
|
||||
|
||||
let encoder_error: Closure<dyn FnMut(JsValue)> =
|
||||
Closure::new(|e| error!("error encoding audio {:?}", e));
|
||||
|
||||
// Shared state to signal terminator between onmessage and output closures
|
||||
// The output closure runs asynchronously after encoding completes
|
||||
let pending_terminator = Arc::new(AtomicCell::new(false));
|
||||
let pending_terminator_output = pending_terminator.clone();
|
||||
|
||||
// This knows what MediaStreamTrackGenerator to use as it closes around it
|
||||
let output: Closure<dyn FnMut(EncodedAudioChunk)> =
|
||||
Closure::new(move |audio_data: EncodedAudioChunk| {
|
||||
let mut array = vec![0u8; audio_data.byte_length() as usize];
|
||||
audio_data.copy_to_with_u8_slice(&mut array);
|
||||
// Check if this frame was marked as a terminator
|
||||
let is_terminator = pending_terminator_output.swap(false);
|
||||
each(array, is_terminator);
|
||||
});
|
||||
|
||||
let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new(
|
||||
encoder_error.as_ref().unchecked_ref(),
|
||||
output.as_ref().unchecked_ref(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// This is required to prevent these from being deallocated
|
||||
encoder_error.forget();
|
||||
output.forget();
|
||||
let encoder_config = AudioEncoderConfig::new("opus");
|
||||
encoder_config.set_number_of_channels(1);
|
||||
encoder_config.set_sample_rate(48000);
|
||||
encoder_config.set_bitrate(72_000.0);
|
||||
|
||||
audio_encoder.configure(&encoder_config);
|
||||
info!("created audio encoder");
|
||||
|
||||
let mut current_processor = AudioProcessor::new(false);
|
||||
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
|
||||
if let Some(new_processor) = processors.take() {
|
||||
current_processor = new_processor;
|
||||
}
|
||||
|
||||
let frame = event.data();
|
||||
let state = process_audio(&frame, &mut current_processor);
|
||||
|
||||
match state {
|
||||
TransmitState::Silent => {
|
||||
// Don't encode or send anything
|
||||
return;
|
||||
}
|
||||
TransmitState::Transmitting => (), // Normal transmission
|
||||
TransmitState::Terminator => {
|
||||
// Mark this as a terminator before encoding
|
||||
pending_terminator.store(true);
|
||||
}
|
||||
}
|
||||
match AudioData::new(frame.unchecked_ref()) {
|
||||
Ok(data) => {
|
||||
let _ = audio_encoder.encode(&data);
|
||||
}
|
||||
Err(err) => {
|
||||
error!(
|
||||
"error creating AudioData object {:?} during event {:?}",
|
||||
err, event,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reflect::set(
|
||||
&Reflect::get(&worklet_node, &"port".into()).ey()?,
|
||||
&"onmessage".into(),
|
||||
onmessage.as_ref(),
|
||||
)
|
||||
.ey()?;
|
||||
onmessage.forget();
|
||||
|
||||
source.connect_with_audio_node(&worklet_node).ey()?;
|
||||
worklet_node
|
||||
.connect_with_audio_node(&audio_context.destination())
|
||||
.ey()?;
|
||||
|
||||
Ok(worklet_node)
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
pub async fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
overrides: &ProxyOverrides,
|
||||
state: SharedState,
|
||||
) -> Result<(), Error> {
|
||||
info!("connecting");
|
||||
|
||||
let object = web_sys::js_sys::Object::new();
|
||||
|
||||
Reflect::set(
|
||||
&object,
|
||||
&JsValue::from_str("algorithm"),
|
||||
&JsValue::from_str("sha-256"),
|
||||
)
|
||||
.ey()?;
|
||||
|
||||
if let Some(server_hash) = &overrides.cert_hash {
|
||||
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
|
||||
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let (outgoing_send, outgoing_recv) = futures_channel::mpsc::unbounded();
|
||||
spawn(crate::sender_loop(outgoing_recv, writer));
|
||||
crate::network_loop(username, state, event_rx, outgoing_send, reader).await
|
||||
}
|
||||
|
||||
pub fn absolute_url(path: &str) -> Result<Url, Error> {
|
||||
let window: web_sys::Window = web_sys::window().expect("no global `window` exists");
|
||||
let location = window.location();
|
||||
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct WebConfigSystem {}
|
||||
|
||||
impl super::ConfigSystemInterface for WebConfigSystem {
|
||||
fn new() -> Result<Self, Error> {
|
||||
return Ok(WebConfigSystem {});
|
||||
}
|
||||
|
||||
fn config_get<T>(&self, key: &str) -> Option<T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
// Get Storage
|
||||
let storage = web_sys::window()?.local_storage().ok()??;
|
||||
|
||||
// Try localStorage first
|
||||
if let Ok(Some(raw)) = storage.get_item(key) {
|
||||
if let Ok(parsed) = serde_json::from_str::<T>(&raw) {
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default if deserialization fails or key missing
|
||||
let default_value = config_get_default(key)?;
|
||||
serde_json::from_value::<T>(default_value).ok()
|
||||
}
|
||||
|
||||
fn config_set<T>(&self, key: &str, value: &T)
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let storage = window()
|
||||
.and_then(|w| w.local_storage().ok().flatten())
|
||||
.expect("localStorage not available");
|
||||
|
||||
let json_value =
|
||||
serde_json::to_string(value).expect("failed to serialize config value to JSON string");
|
||||
|
||||
storage
|
||||
.set_item(key, &json_value)
|
||||
.expect("failed to write to localStorage");
|
||||
}
|
||||
}
|
||||
|
||||
fn config_get_default(key: &str) -> Option<serde_json::Value> {
|
||||
let default_config = platform_default_config();
|
||||
default_config
|
||||
.get(key)
|
||||
.cloned()
|
||||
.or(super::global_default_config().get(key).cloned())
|
||||
}
|
||||
|
||||
fn platform_default_config() -> HashMap<String, serde_json::Value> {
|
||||
serde_json::json!({})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod app;
|
||||
mod effects;
|
||||
mod imp;
|
||||
mod mainloop;
|
||||
mod msghtml;
|
||||
|
||||
pub use app::*;
|
||||
pub use imp::*;
|
||||
pub use mainloop::*;
|
||||
pub use mime_guess;
|
||||
pub use reqwest;
|
||||
@@ -1,72 +1,89 @@
|
||||
use app::Chat;
|
||||
use app::Command;
|
||||
use app::ConnectionState;
|
||||
use app::STATE;
|
||||
use crate::msghtml::process_message_html;
|
||||
use crate::AudioSettings;
|
||||
use crate::Chat;
|
||||
use crate::Command;
|
||||
use crate::ConnectionState;
|
||||
use asynchronous_codec::FramedRead;
|
||||
use asynchronous_codec::FramedWrite;
|
||||
use color_eyre::eyre::{bail, Error};
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_signals::ReadableExt as _;
|
||||
use dioxus_signals::WritableExt as _;
|
||||
use futures::select;
|
||||
use futures::AsyncRead;
|
||||
use futures::AsyncWrite;
|
||||
use futures::FutureExt as _;
|
||||
use futures::SinkExt as _;
|
||||
use futures::StreamExt as _;
|
||||
use futures_channel::mpsc::UnboundedSender;
|
||||
pub use imp::spawn;
|
||||
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
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::GuiConfig;
|
||||
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;
|
||||
|
||||
pub mod app;
|
||||
pub mod imp;
|
||||
use crate::app::SharedState;
|
||||
use crate::app::State;
|
||||
use crate::effects::AudioProcessor;
|
||||
use crate::imp::{
|
||||
spawn, AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _,
|
||||
Platform, PlatformInterface as _,
|
||||
};
|
||||
|
||||
pub static CONFIG: Lazy<GuiConfig> = Lazy::new(|| imp::load_config().unwrap_or_default());
|
||||
|
||||
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
|
||||
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>, state: SharedState) {
|
||||
loop {
|
||||
let Some(Command::Connect { address, username }) = event_rx.next().await else {
|
||||
let Some(Command::Connect {
|
||||
target,
|
||||
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 {
|
||||
*state.server.write_unchecked() = Default::default();
|
||||
*state.status.write_unchecked() = ConnectionState::Connecting;
|
||||
if let Err(error) =
|
||||
Platform::network_connect(target, username, &mut event_rx, &config, state.clone())
|
||||
.await
|
||||
{
|
||||
error!("could not connect {:?}", error);
|
||||
*STATE.status.write() = ConnectionState::Failed(error.to_string());
|
||||
*state.status.write_unchecked() = ConnectionState::Failed(error.to_string());
|
||||
} else {
|
||||
*STATE.status.write() = ConnectionState::Disconnected;
|
||||
*state.status.write_unchecked() = ConnectionState::Disconnected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
|
||||
pub(crate) async fn sender_loop<W: AsyncWrite + Unpin + 'static>(
|
||||
mut outgoing: UnboundedReceiver<ControlPacket<Serverbound>>,
|
||||
mut writer: FramedWrite<W, ControlCodec<Serverbound, Clientbound>>,
|
||||
) -> Result<(), Error> {
|
||||
let (mut send_chan, mut writer_recv_chan) = futures_channel::mpsc::unbounded();
|
||||
spawn(async move {
|
||||
while let Some(msg) = writer_recv_chan.next().await {
|
||||
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
|
||||
info!("sending packet {:#?}", msg);
|
||||
}
|
||||
if let Err(e) = writer.send(msg).await {
|
||||
error!("error sending packet {:?}", e);
|
||||
break;
|
||||
}
|
||||
) {
|
||||
while let Some(msg) = outgoing.next().await {
|
||||
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
|
||||
info!("sending packet {:#?}", msg);
|
||||
}
|
||||
});
|
||||
if let Err(e) = writer.send(msg).await {
|
||||
error!("error sending packet {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn network_loop<R: AsyncRead + Unpin + 'static>(
|
||||
username: String,
|
||||
state: SharedState,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
mut outgoing: UnboundedSender<ControlPacket<Serverbound>>,
|
||||
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
|
||||
) -> Result<(), Error> {
|
||||
let audio_settings = state.audio.read().clone();
|
||||
|
||||
// Get version packet
|
||||
let version = match reader.next().await {
|
||||
@@ -81,29 +98,48 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
|
||||
msg.set_version(0x000010204);
|
||||
msg.set_release(format!("{} {}", "mumbleweb2", "6.9.0"));
|
||||
//msg.set_os("Chrome".to_string());
|
||||
send_chan.send(msg.into()).await.unwrap();
|
||||
outgoing.send(msg.into()).await.unwrap();
|
||||
|
||||
// Send authenticate packet
|
||||
let mut msg = msgs::Authenticate::new();
|
||||
msg.set_username(username);
|
||||
msg.set_opus(true);
|
||||
send_chan.send(msg.into()).await.unwrap();
|
||||
outgoing.send(msg.into()).await.unwrap();
|
||||
|
||||
// Spawn worker to send pings
|
||||
{
|
||||
let mut send_chan = send_chan.clone();
|
||||
let mut send_chan = outgoing.clone();
|
||||
spawn(async move {
|
||||
loop {
|
||||
if let Err(_) = send_chan.send(msgs::Ping::new().into()).await {
|
||||
break;
|
||||
}
|
||||
|
||||
imp::sleep(Duration::from_millis(3000)).await;
|
||||
Platform::sleep(Duration::from_millis(3000)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut audio = imp::AudioSystem::new(send_chan.clone())?;
|
||||
let mut audio = AudioSystem::new().await?;
|
||||
audio.set_processor(AudioProcessor::new(audio_settings.denoise));
|
||||
{
|
||||
let send_chan = outgoing.clone();
|
||||
let mut sequence_num = 0;
|
||||
if let Err(err) = audio.start_recording(move |opus_frame, is_terminator| {
|
||||
let _ =
|
||||
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
|
||||
_dst: std::marker::PhantomData,
|
||||
target: 0,
|
||||
session_id: (),
|
||||
seq_num: sequence_num,
|
||||
payload: VoicePacketPayload::Opus(opus_frame.into(), is_terminator),
|
||||
position_info: None,
|
||||
})));
|
||||
sequence_num = sequence_num.wrapping_add(2);
|
||||
}) {
|
||||
error!("could not begin recording: {err:?}")
|
||||
}
|
||||
}
|
||||
|
||||
// Create map of session_id -> AudioDecoder
|
||||
let mut decoder_map = HashMap::new();
|
||||
@@ -120,7 +156,7 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
|
||||
if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) {
|
||||
info!("receiving packet {:#?}", msg);
|
||||
}
|
||||
let res = accept_packet(msg, &mut audio, &mut decoder_map);
|
||||
let res = accept_packet(msg, &mut audio, &mut decoder_map, &state);
|
||||
if let Err(err) = res {
|
||||
error!("error accepting packet {:?}", err)
|
||||
}
|
||||
@@ -139,7 +175,7 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
|
||||
match command {
|
||||
Some(Command::Disconnect) => break,
|
||||
Some(command) => {
|
||||
let res = accept_command(command, &mut send_chan);
|
||||
let res = accept_command(command, &mut outgoing, &mut audio, &state);
|
||||
if let Err(err) = res {
|
||||
info!("error accepting command {:?}", err)
|
||||
}
|
||||
@@ -149,7 +185,7 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = send_chan.close();
|
||||
let _ = outgoing.close();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -157,9 +193,11 @@ 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,
|
||||
state: &State,
|
||||
) -> Result<(), Error> {
|
||||
use Command::*;
|
||||
let Some(session) = STATE.server.read().session else {
|
||||
let Some(session) = state.server.read().session else {
|
||||
bail!("no session id")
|
||||
};
|
||||
|
||||
@@ -182,7 +220,7 @@ fn accept_command(
|
||||
};
|
||||
|
||||
{
|
||||
let mut server = STATE.server.write();
|
||||
let mut server = state.server.write_unchecked();
|
||||
let Some(me) = server.session else {
|
||||
bail!("not signed in with a session id")
|
||||
};
|
||||
@@ -198,6 +236,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_unchecked();
|
||||
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);
|
||||
@@ -217,6 +296,9 @@ fn accept_command(
|
||||
let _ = send_chan.unbounded_send(u.into());
|
||||
}
|
||||
Connect { .. } | Disconnect => (),
|
||||
UpdateAudioSettings(AudioSettings { denoise }) => {
|
||||
audio.set_processor(AudioProcessor::new(denoise));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -224,8 +306,9 @@ fn accept_command(
|
||||
|
||||
fn accept_packet(
|
||||
msg: ControlPacket<mumble_protocol::Clientbound>,
|
||||
audio_context: &mut imp::AudioSystem,
|
||||
player_map: &mut HashMap<u32, imp::AudioPlayer>,
|
||||
audio_context: &mut AudioSystem,
|
||||
player_map: &mut HashMap<u32, AudioPlayer>,
|
||||
state: &State,
|
||||
) -> Result<(), Error> {
|
||||
match msg {
|
||||
ControlPacket::UDPTunnel(u) => {
|
||||
@@ -262,45 +345,15 @@ fn accept_packet(
|
||||
}
|
||||
}
|
||||
ControlPacket::ChannelState(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let id = u.get_channel_id();
|
||||
|
||||
let state = server.channels.entry(id).or_default();
|
||||
let new_parent = if u.has_parent() {
|
||||
if let Some(parent) = state.parent.and_then(|p| server.channels.get_mut(&p)) {
|
||||
parent.children.remove(&id);
|
||||
}
|
||||
|
||||
let parent_id = u.get_parent();
|
||||
let parent = server.channels.entry(parent_id).or_default();
|
||||
if u.has_position() && u.get_position() as usize <= parent.children.len() {
|
||||
// TODO: what if positions are received out of order? we need to sort afterwards?
|
||||
parent.children.insert_before(u.get_position() as usize, id);
|
||||
} else {
|
||||
parent.children.insert(id);
|
||||
}
|
||||
Some(parent_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state = server.channels.entry(id).or_default();
|
||||
state.parent = new_parent;
|
||||
if u.has_name() {
|
||||
state.name = u.get_name().to_string();
|
||||
}
|
||||
let mut server = state.server.write_unchecked();
|
||||
server.channels_state.update_from_channel_state(&u);
|
||||
}
|
||||
ControlPacket::ChannelRemove(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let id = u.get_channel_id();
|
||||
if let Some(channel) = server.channels.remove(&id) {
|
||||
if let Some(parent) = channel.parent.and_then(|p| server.channels.get_mut(&p)) {
|
||||
parent.children.remove(&id);
|
||||
}
|
||||
}
|
||||
let mut server = state.server.write_unchecked();
|
||||
server.channels_state.update_from_channel_remove(&u);
|
||||
}
|
||||
ControlPacket::UserState(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let mut server = state.server.write_unchecked();
|
||||
let server = &mut *server;
|
||||
let id = u.get_session();
|
||||
|
||||
@@ -309,12 +362,13 @@ fn accept_packet(
|
||||
let state = state_entry.or_default();
|
||||
// the server might now send a channel_id if the user is in channel=0
|
||||
if u.has_channel_id() || new {
|
||||
if let Some(parent) = server.channels.get_mut(&state.channel) {
|
||||
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
||||
parent.users.remove(&id);
|
||||
}
|
||||
|
||||
let channel_id = u.get_channel_id();
|
||||
server
|
||||
.channels_state
|
||||
.channels
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
@@ -332,6 +386,9 @@ fn accept_packet(
|
||||
if u.has_deaf() {
|
||||
state.deaf = u.get_deaf();
|
||||
}
|
||||
if u.has_suppress() {
|
||||
state.suppress = u.get_suppress();
|
||||
}
|
||||
if u.has_self_mute() {
|
||||
state.self_mute = u.get_self_mute();
|
||||
}
|
||||
@@ -340,16 +397,16 @@ fn accept_packet(
|
||||
}
|
||||
}
|
||||
ControlPacket::UserRemove(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let mut server = state.server.write_unchecked();
|
||||
let id = u.get_session();
|
||||
if let Some(state) = server.users.remove(&id) {
|
||||
if let Some(parent) = server.channels.get_mut(&state.channel) {
|
||||
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
||||
parent.users.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlPacket::TextMessage(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let mut server = state.server.write_unchecked();
|
||||
if u.has_message() {
|
||||
let text = u.get_message().to_string();
|
||||
server.chat.push(Chat {
|
||||
@@ -358,19 +415,19 @@ fn accept_packet(
|
||||
} else {
|
||||
None
|
||||
},
|
||||
dangerous_html: html_purifier::purifier(&text, Default::default()),
|
||||
dangerous_html: process_message_html(&text),
|
||||
raw: text,
|
||||
});
|
||||
}
|
||||
}
|
||||
ControlPacket::ServerSync(u) => {
|
||||
*STATE.status.write() = ConnectionState::Connected;
|
||||
let mut server = STATE.server.write();
|
||||
*state.status.write_unchecked() = ConnectionState::Connected;
|
||||
let mut server = state.server.write_unchecked();
|
||||
if u.has_welcome_text() {
|
||||
let text = u.get_welcome_text().to_string();
|
||||
server.chat.push(Chat {
|
||||
sender: None,
|
||||
dangerous_html: html_purifier::purifier(&text, Default::default()),
|
||||
dangerous_html: process_message_html(&text),
|
||||
raw: text,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,5 +3,10 @@ name = "mumble-web2-common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
networking = ["dep:tokio", "dep:color-eyre"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
tokio = { version = "1", features = ["net", "time"], optional = true }
|
||||
color-eyre = { version = "0.6", optional = true }
|
||||
|
||||
@@ -1,9 +1,87 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, Default)]
|
||||
pub struct GuiConfig {
|
||||
#[serde(default)]
|
||||
pub force_proxy: bool,
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
pub struct ProxyOverrides {
|
||||
pub proxy_url: Option<String>,
|
||||
pub cert_hash: Option<Vec<u8>>,
|
||||
pub any_server: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
pub struct ServerStatus {
|
||||
#[serde(default)]
|
||||
pub success: bool,
|
||||
pub version: Option<(u32, u32, u32)>,
|
||||
pub users: Option<u32>,
|
||||
pub max_users: Option<u32>,
|
||||
pub bandwidth: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq)]
|
||||
pub struct ServerEntry {
|
||||
pub name: String,
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
/// Mumble UDP ping protocol.
|
||||
///
|
||||
/// Send a 12-byte packet: 4 zero bytes + 8-byte identifier.
|
||||
/// Receive a 24-byte response: 4 bytes version + 8 bytes identifier echo
|
||||
/// + 4 bytes current_users + 4 bytes max_users + 4 bytes bandwidth.
|
||||
#[cfg(feature = "networking")]
|
||||
pub async fn ping_server(address: &str, port: u16) -> color_eyre::Result<ServerStatus> {
|
||||
use color_eyre::eyre::{bail, eyre};
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Duration;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
let dest = format!("{}:{}", address, port)
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| eyre!("could not resolve address"))?;
|
||||
|
||||
let bind_addr = if dest.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" };
|
||||
let socket = UdpSocket::bind(bind_addr).await?;
|
||||
socket.connect(dest).await?;
|
||||
|
||||
let request_id: u64 = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64;
|
||||
|
||||
let mut buf = [0u8; 12];
|
||||
buf[4..12].copy_from_slice(&request_id.to_be_bytes());
|
||||
socket.send(&buf).await?;
|
||||
|
||||
let mut response = [0u8; 24];
|
||||
let timeout = tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut response)).await;
|
||||
|
||||
match timeout {
|
||||
Ok(Ok(len)) if len >= 24 => {
|
||||
let version_major = response[0] as u32;
|
||||
let version_minor = response[1] as u32;
|
||||
let version_patch = response[2] as u32;
|
||||
let users =
|
||||
u32::from_be_bytes([response[12], response[13], response[14], response[15]]);
|
||||
let max_users =
|
||||
u32::from_be_bytes([response[16], response[17], response[18], response[19]]);
|
||||
let bandwidth =
|
||||
u32::from_be_bytes([response[20], response[21], response[22], response[23]]);
|
||||
|
||||
Ok(ServerStatus {
|
||||
success: true,
|
||||
version: Some((version_major, version_minor, version_patch)),
|
||||
users: Some(users),
|
||||
max_users: Some(max_users),
|
||||
bandwidth: Some(bandwidth),
|
||||
})
|
||||
}
|
||||
Ok(Ok(_)) => bail!("ping response too short"),
|
||||
Ok(Err(e)) => Err(e.into()),
|
||||
Err(_) => bail!("ping timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 @@
|
||||
localhost:64444 {
|
||||
tls internal
|
||||
|
||||
# Proxy /config path to mumble-web2-proxy
|
||||
reverse_proxy /overrides http://127.0.0.1:4400
|
||||
|
||||
# Proxy /status path to mumble-web2-proxy
|
||||
reverse_proxy /status http://127.0.0.1:4400
|
||||
|
||||
# Proxy root path to dx-serve
|
||||
reverse_proxy http://127.0.0.1:8080
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
FROM rust:trixie
|
||||
|
||||
ARG ANDROID_CLI_TOOLS_VERSION=13114758
|
||||
|
||||
# Install android rust toolchains
|
||||
RUN rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
|
||||
|
||||
# Install debian dependencies
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
nodejs \
|
||||
ca-certificates \
|
||||
curl \
|
||||
unzip \
|
||||
default-jdk
|
||||
|
||||
# Install android commandline tools (required to install the sdk)
|
||||
RUN cd /tmp && \
|
||||
curl -o commandlinetools-linux.zip "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CLI_TOOLS_VERSION}_latest.zip" && \
|
||||
unzip commandlinetools-linux.zip && \
|
||||
mkdir -p /opt/android-tools/cmdline-tools && \
|
||||
cp -r cmdline-tools /opt/android-tools/cmdline-tools/latest
|
||||
|
||||
|
||||
# Install required android tools
|
||||
RUN yes | /opt/android-tools/cmdline-tools/latest/bin/sdkmanager --install "platform-tools" "platforms;android-36.1" "build-tools;36.1.0" "ndk;29.0.14206865" "cmake;3.31.6"
|
||||
|
||||
# Install cargo binstall
|
||||
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
|
||||
|
||||
# Install dioxus-cli
|
||||
RUN cargo binstall dioxus-cli@0.7.3
|
||||
|
||||
# Install bindgen-cli
|
||||
RUN cargo binstall bindgen-cli
|
||||
|
||||
# Set required env vars
|
||||
ENV ANDROID_HOME="/opt/android-tools/"
|
||||
ENV NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865"
|
||||
ENV PATH="$PATH:$ANDROID_HOME/platform-tools"
|
||||
ENV PATH="$PATH:/opt/android-tools/cmake/3.31.6/bin/"
|
||||
ENV LLVM_CONFIG_PATH="/opt/android-tools/ndk/29.0.14206865/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-config"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
IMAGE_NAME="mumble-web2/android-release-builder:local"
|
||||
|
||||
TARGET="${1:-aarch64-linux-android}"
|
||||
|
||||
echo "==> Building Android builder Docker image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/android-release-builder.Dockerfile" "$PROJECT_ROOT"
|
||||
|
||||
echo "==> Building Android APK (target: $TARGET)..."
|
||||
docker run --rm \
|
||||
-v "$PROJECT_ROOT:/app" \
|
||||
-w /app \
|
||||
"$IMAGE_NAME" \
|
||||
dx build --platform android --target "$TARGET" --release -p mumble-web2-gui
|
||||
|
||||
echo "==> Done! APK should be at:"
|
||||
echo " target/dx/mumble-web2-gui/release/android/app/app/build/outputs/apk/debug/app-debug.apk"
|
||||
@@ -0,0 +1,58 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:latest
|
||||
ports:
|
||||
- "64444:64444/tcp"
|
||||
- "64444:64444/udp"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:z
|
||||
#- caddy_data:/data
|
||||
#- caddy_config:/config
|
||||
depends_on:
|
||||
#- dx-serve
|
||||
- mumble-web2-proxy
|
||||
network_mode: host
|
||||
|
||||
#dx-serve:
|
||||
# build:
|
||||
# dockerfile: ./dioxus.Dockerfile
|
||||
# working_dir: /app
|
||||
# volumes:
|
||||
# - ..:/app
|
||||
# environment:
|
||||
# - MUMBLE_WEB2_PROXY_OVERRIDES_URL=https://localhost:64444/overrides
|
||||
# stdin_open: true
|
||||
# tty: true
|
||||
# command: >
|
||||
# bash -c "
|
||||
# screen -dmS serve bash -c 'dx serve -p mumble-web2-gui --platform web' &&
|
||||
# tail -f /dev/null
|
||||
# "
|
||||
# networks:
|
||||
# - app-network
|
||||
|
||||
mumble-web2-proxy:
|
||||
image: rust:latest
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ..:/app:z
|
||||
- ./proxy-config.toml:/app/config.toml:z
|
||||
ports:
|
||||
- "4433:4433/tcp"
|
||||
- "4433:4433/udp"
|
||||
command: ["cargo", "run", "-p", "mumble-web2-proxy", "--locked"]
|
||||
network_mode: host
|
||||
|
||||
mumble-server:
|
||||
image: mumblevoip/mumble-server:latest
|
||||
ports:
|
||||
- "64738:64738/tcp"
|
||||
- "64738:64738/udp"
|
||||
network_mode: host
|
||||
#volumes:
|
||||
# caddy_data:
|
||||
# caddy_config:
|
||||
#
|
||||
#networks:
|
||||
# app-network:
|
||||
# driver: bridge
|
||||
@@ -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,55 @@
|
||||
# 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 cargo binstall
|
||||
RUN Set-ExecutionPolicy Unrestricted -Scope Process; `
|
||||
iex (Invoke-WebRequest "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.ps1" -UseBasicParsing).Content
|
||||
|
||||
SHELL ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
|
||||
# Install dioxus-cli
|
||||
RUN cargo binstall dioxus-cli@0.7.3
|
||||
|
||||
ENTRYPOINT ["C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\Common7\\Tools\\VsDevCmd.bat", "&&", "powershell.exe", "-NoLogo", "-ExecutionPolicy", "Bypass"]
|
||||
@@ -4,100 +4,31 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Web Dependencies
|
||||
# ================
|
||||
dioxus-web = { version = "0.6.0-alpha.4", 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",
|
||||
"MediaStreamTrackGenerator",
|
||||
"MediaStreamTrackGeneratorInit",
|
||||
"AudioContext",
|
||||
"AudioContextOptions",
|
||||
"MediaStream",
|
||||
"GainNode",
|
||||
"MediaStreamAudioSourceNode",
|
||||
"BaseAudioContext",
|
||||
"AudioDestinationNode",
|
||||
"AudioWorkletNode",
|
||||
"AudioWorklet",
|
||||
"AudioWorkletProcessor",
|
||||
"MediaStreamConstraints",
|
||||
"WorkletOptions",
|
||||
"AudioEncoder",
|
||||
"AudioEncoderInit",
|
||||
"AudioDataInit",
|
||||
"HtmlAnchorElement",
|
||||
"Url",
|
||||
"Blob",
|
||||
"AudioDataCopyToOptions",
|
||||
"AudioSampleFormat",
|
||||
"Storage",
|
||||
], optional = true}
|
||||
gloo-timers = { version = "0.3.0", features = ["futures"], optional = true }
|
||||
tracing-web = { version = "0.1.3", optional = true }
|
||||
dioxus = { version = "0.7.2" }
|
||||
dioxus-web = { version = "0.7.2", optional = true }
|
||||
mumble-web2-client = { version = "0.1.0", path = "../client" }
|
||||
mumble-web2-common = { version = "0.1.0", path = "../common" }
|
||||
color-eyre = "^0.6.3"
|
||||
|
||||
# Desktop Dependecies
|
||||
# ===================
|
||||
dioxus-desktop = { version = "0.6.0-alpha.4", optional = true}
|
||||
tokio = { version = "1.41.1", features = ["net", "rt"], optional = true }
|
||||
tokio-rustls = { version = "0.26.0", optional = true }
|
||||
|
||||
|
||||
# Base Dependencies
|
||||
# ================
|
||||
dioxus = { version = "0.6.0-alpha.4" }
|
||||
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.0.117"
|
||||
tokio-util = { version = "0.7.11", features = ["codec", "compat"] }
|
||||
byteorder = "1.5.0"
|
||||
ogg = "0.9.1"
|
||||
ordermap = "0.5.3"
|
||||
html-purifier = "0.3.0"
|
||||
markdown = "0.3.0"
|
||||
futures-channel = "0.3.30"
|
||||
sir = { git = "https://gitlab.com/samsartor/sir", features = ["dioxus"] } # dioxus 0.6
|
||||
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"
|
||||
# Platform Integration
|
||||
# ====================
|
||||
# rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS
|
||||
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true }
|
||||
|
||||
[features]
|
||||
web = [
|
||||
"dioxus/web",
|
||||
"dioxus-web",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"serde-wasm-bindgen",
|
||||
"js-sys",
|
||||
"web-sys",
|
||||
"gloo-timers",
|
||||
"tracing-web",
|
||||
"mumble-web2-client/web",
|
||||
"rfd",
|
||||
]
|
||||
desktop = [
|
||||
"dioxus/desktop",
|
||||
"mumble-web2-client/desktop",
|
||||
"rfd/xdg-portal",
|
||||
]
|
||||
mobile = [
|
||||
"dioxus/mobile",
|
||||
"mumble-web2-client/mobile"
|
||||
]
|
||||
desktop = ["dioxus/desktop", "tokio", "tokio-rustls", "tracing-subscriber/env-filter"]
|
||||
|
||||
@@ -8,10 +8,11 @@ out_dir = "dist"
|
||||
# resource (public) file folder
|
||||
asset_dir = "public"
|
||||
|
||||
android_manifest = "build/AndroidManifest.xml"
|
||||
|
||||
[web.app]
|
||||
# HTML title tag content
|
||||
title = "Mumble Web 2"
|
||||
base_path = "gui"
|
||||
|
||||
[web.watcher]
|
||||
# when watcher trigger, regenerate the `index.html`
|
||||
@@ -24,7 +25,7 @@ watch_path = ["src", "assets"]
|
||||
# CSS style file
|
||||
style = []
|
||||
# Javascript code file
|
||||
script = []
|
||||
script = ["loader.js"]
|
||||
|
||||
[web.resource.dev]
|
||||
# serve: [dev-server] only
|
||||
@@ -32,3 +33,14 @@ script = []
|
||||
style = []
|
||||
# Javascript code file
|
||||
script = []
|
||||
|
||||
[bundle]
|
||||
identifier = "xyz.ohea.mumble_web_2"
|
||||
publisher = "OheaCorp"
|
||||
icon = [
|
||||
"icons/32x32.png",
|
||||
"icons/256x256.png",
|
||||
"assets/favicon.ico",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?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">
|
||||
<path fill="#000000" fill-rule="evenodd" d="M11.7071,4.29289 L15.4142,8 L11.7071,11.7071 C11.3166,12.0976 10.6834,12.0976 10.2929,11.7071 C9.90237,11.3166 9.90237,10.6834 10.2929,10.2929 L11.5858,9 L2,9 C1.44771,9 1,8.55228 1,8 C1,7.44772 1.44771,7 2,7 L11.5858,7 L10.2929,5.70711 C9.90237,5.31658 9.90237,4.68342 10.2929,4.29289 C10.6834,3.90237 11.3166,3.90237 11.7071,4.29289 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 601 B |
@@ -0,0 +1,8 @@
|
||||
<?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 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 12V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 12V17" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 7H20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 10V18C6 19.6569 7.34315 21 9 21H15C16.6569 21 18 19.6569 18 18V10" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 862 B |
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#000000;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M491.878,156.348C472.437,110.39,439.989,71.33,399.14,43.731C358.307,16.131,308.964-0.008,256,0
|
||||
c-35.304,0-69.011,7.167-99.652,20.122C110.39,39.564,71.33,72.011,43.731,112.86C16.131,153.693-0.008,203.036,0,256
|
||||
c0,35.304,7.167,69.02,20.122,99.653c19.442,45.957,51.889,85.016,92.738,112.616c40.832,27.6,90.176,43.74,143.14,43.731
|
||||
c35.305,0,69.02-7.166,99.653-20.122c45.957-19.442,85.017-51.889,112.617-92.738c27.6-40.832,43.74-90.176,43.731-143.14
|
||||
C512,220.697,504.842,186.98,491.878,156.348z M427.814,110.348c0.774,0.915,1.53,1.856,2.294,2.789
|
||||
c-1.496-0.454-2.991-0.908-4.486-1.37C426.353,111.297,427.084,110.819,427.814,110.348z M382.832,101.182
|
||||
C387.142,100.754,380.446,101.434,382.832,101.182c-1.798-0.126-3.159-0.858-4.066-2.177
|
||||
C384.579,95.217,388.747,100.585,382.832,101.182z M290.917,81.127c1.613,4.142-9.956,0.277-11.216-0.336
|
||||
c-0.739-0.739-1.294-1.58-1.663-2.52C278.021,79.203,290.388,79.749,290.917,81.127z M258.79,75.406
|
||||
c2.823,0.958,14.022-1.572,14.383,1.722c0.673,6.049-3.99,0.058-4.956,0.058c-2.622,0,1.21,2.78,1.31,2.923
|
||||
c-0.656-0.957-8.461-0.857-10.107-0.462C254.656,88.352,241.675,69.592,258.79,75.406z M271.711,87.026
|
||||
c-3.108,1.142-8.443,0.168-11.754,0.168C253.808,85.58,276.718,85.194,271.711,87.026z M79.236,313.19
|
||||
C79.06,315.812,79.346,311.544,79.236,313.19c0.042-0.663,1.126-5.755,2.084-6.049c1.513-0.453,4.613,6.999,4.487,8.041
|
||||
C85.11,321.206,78.892,318.148,79.236,313.19z M136.15,339.169c-3.252-3.983-5.192-8.461-8.284-12.528
|
||||
c-0.63-0.84-11.031-6.754-9.058-7.796c4.243-2.21,39.505,18.517,37.934,21.676c-0.152,0.303-7.889-6.797-9.847-5.52
|
||||
c-1.302,0.848,2.689,3.932,2.689,5.419c2.016,1.033,7.149,8.646,2.932,8.872C147.862,349.545,138.872,342.512,136.15,339.169z
|
||||
M154.894,340.546c0.21,1.37-3.646-1.185-3.873-1.277C151.777,337.884,154.718,339.412,154.894,340.546z M151.92,353.208
|
||||
c-3.898-2.487,12.569-0.554,13.459-0.487c3.252,0.243,11.418,0.076,13.552,3.974C179.569,357.862,155.574,355.544,151.92,353.208
|
||||
C154.356,354.77,150.122,352.066,151.92,353.208z M188.686,317.644c4.125-4.201,8.839,3.235,9.174,2.932
|
||||
c-2.596,2.369-6.486,6.94-1.252,9.671c3.932,2.058-4.672,3.537-4.672,4.268c0,2.966-4.771,10.795-7.814,12.014
|
||||
c0.177-0.067-11.913-3.419-12.972-3.722c-2.638-0.764-3.445-8.082-5.058-10.426C166.093,328.036,184.51,321.895,188.686,317.644z
|
||||
M167.53,281.869c1.756-1.328,3.378-1.479,4.848-0.454C173.546,287.801,165.514,282.785,167.53,281.869z M195.718,306.864
|
||||
c0.009-0.017,0.017-0.025,0.034-0.034c0.067-0.059,0.101-0.084,0.092-0.076c1.05-0.857,3.806-3.302,4.898-3.546
|
||||
C203.716,302.529,193.676,308.502,195.718,306.864z M201.658,294.278c-0.563-3.353-0.151-3.974,0.093-6.68
|
||||
c0.428-4.713,5.915-6.772,7.007-1.092c0.193,0.975-5.721,9.88-1.218,9.317c3.612-0.454,6.301-0.622,5.134,3.932
|
||||
c-1.076-0.714-7.814-4.075-8.637-3.898c-0.798,0.168-3.36,8.746-6.049,7.939C199.162,304.151,201.809,295.95,201.658,294.278z
|
||||
M200.734,269.073c0.084-1.328,2.042-5.125,4.176-4.688c2.461,0.513-0.731,10.141-2.487,9.032c-1.26-1.193-1.823-2.639-1.689-4.335
|
||||
C200.7,269.678,200.608,271.199,200.734,269.073z M213.479,300.403c0.016-0.008,0.016-0.016,0.034-0.016
|
||||
c4.797-2.42,3.452,4.218,3.965,4.452C216.831,304.546,213.101,300.613,213.479,300.403z M218.184,308.04
|
||||
C217.26,307.889,219.276,308.217,218.184,308.04c1.521,0.244,2.269,3.369,2.42,4.378c0.621,4.243,0.546,2.411-2.26,3.52
|
||||
c-0.765,0.294,0.084,2.747-1.672,2.865c-0.84,0.05-5.436-0.908-3.948-2.672c3.091-3.663-5.663-0.815-5.663-1.134
|
||||
c0-1.227,1.386-4.075,3.184-3.697C215.504,312.384,215.58,307.62,218.184,308.04z M223.435,335.086
|
||||
c-1.311-1.748-0.118-6.075,1.159-7.512c-1.016,1.142,2.706,3.058,2.882,2.26C227.081,331.632,223.661,335.388,223.435,335.086z
|
||||
M230.03,343.202c-0.076-0.739-6.041-0.588-6.948-0.73C221.51,342.471,229.593,338.766,230.03,343.202z M223.704,262.839
|
||||
c-3.907,0.89,0.816-2.747,1.664-2.823C226.518,259.907,223.914,262.554,223.704,262.839z M222.393,355.989
|
||||
c1.529,3.941-8.503,4.672-9.906,5.008c1.076-0.261,7.284-2.588,7.898-4.26C220.494,356.426,221.612,353.964,222.393,355.989z
|
||||
M200.557,343.369c-2.243,1.227,0.21,5.831-2.16,6.066c-3.503,0.352-1.638-8.755-0.866-11.208
|
||||
c3.798-11.998,9.352,4.495,12.469-3.47c-0.252,0.647-10.847-2.655-9.091-3.201c0.025-0.008,0.051-0.008,0.076-0.017
|
||||
c2.89-0.873,12.997-3.184,15.408-2.596c0.571,0.134-6.662,10.132-10.636,9.83c4.798,0.369,2.705,7.419,0.941,9.351
|
||||
c-0.63,0.689,4.411,2.646,3.722,3.117C207.993,352.914,197.574,345,200.557,343.369z M206.497,356.938
|
||||
c0.597,0.303-2.731,1.025-3.159,1.109c-1.377,0.47-2.688,0.412-3.932-0.176C198.465,357.115,205.691,356.535,206.497,356.938z
|
||||
M199.297,358.946c-0.647,0.404-1.336,0.404-2.067,0C195.256,357.744,198.348,357.938,199.297,358.946z M205.548,300.05
|
||||
c0,0,0,0-0.009,0c-0.025-0.025-0.051-0.042-0.076-0.058c0.017,0.008,0.034,0.025,0.058,0.041c-0.235-0.159-2.68-1.814-0.84-2.008
|
||||
C207.278,297.757,205.582,300.076,205.548,300.05z M208.783,308.284c-2.798,0-3.016-4.05-3.134-5.621
|
||||
C207.748,296.421,209.429,308.284,208.783,308.284z M209.975,358.132c0.74-0.387,8.208-2.403,7.704-1.613
|
||||
c-0.471,0.748-8.838,4.604-9.679,2.924C208.228,358.527,208.883,358.09,209.975,358.132z M218.839,343.202c0.008,0,0.017,0,0.017,0
|
||||
c-0.026,0-0.009,0-0.026,0c-1.142,0.051-1.622-0.328-1.403-1.143c2.445-1.126,3.344,1.303,1.429,1.143
|
||||
C219.318,343.244,219.091,343.226,218.839,343.202z M209.707,304.738c-0.093-0.042-0.06-0.025-0.009-0.008
|
||||
c-1.294-0.512-0.109-3.394,1.084-2.118C211.177,303.041,210.454,305.016,209.707,304.738z M212.244,305.528
|
||||
c-0.084,0.68-0.605,3.453-1.84,3.453c-0.218-0.74-0.218-1.479,0-2.218C210.539,305.629,211.151,305.218,212.244,305.528z
|
||||
M202.355,305.184L202.355,305.184c-0.58,1.168-7.78,9.838-8.839,5.184C193.542,310.486,201.884,306.142,202.355,305.184z
|
||||
M194.903,356.737c-1.512,0.395-2.789,1.294-4.47,0.344C188.719,356.115,194.836,356.737,194.903,356.737z M187.451,356.325
|
||||
c-0.992,0-0.446-0.656,0.268-0.739C188.442,355.502,188.35,356.325,187.451,356.325z M281.365,457.809
|
||||
c-5.629,13.039-11.771-7.864-10.998-7.62C277.752,452.726,286.145,446.736,281.365,457.809z M293.849,421.691
|
||||
c-1.949,5.721-3.814,11.728-7.343,16.845c-4.864,7.049-8.561,3.294-15.064,3.234c-3.723-0.034-2.849,2.866-6.916,1.278
|
||||
c-2.285-0.899-5.402-1.597-7.444-2.916c2.521,1.63-4.394-9.427-3.764-5.411c-0.344-2.167-2.823-1.084-4.848-1.479
|
||||
c1.093-1.386,1.042-3.537,2.151-4.915c-2.747-0.067-5.091,1.135-6.814,3.277c-7.738-9.561-14.93-10.452-27.372-7.327
|
||||
c-2.882,0.723-8.604,5.361-10.208,5.361c-6.537,0-8.041-0.261-12.914,3.596c-3.344,2.646-11.703,0.84-12.678-3.596
|
||||
c-0.446-2.016,3.218-4.797,3.226-6.89c0.008-1.327-3.84-2.596-4.31-3.94c-0.723-2.108,1-4.596-0.781-6.343
|
||||
c-0.748-0.731-4.125-3.15-4.243-4.15c-0.151-1.319,2.874-2.731,2.874-4.386c0-2.336,0-4.663,0-6.999
|
||||
c0-5.284,14.291-6.46,19.962-9.057c6.629-3.025,1.764-4.906,5.881-8.427c1.236-1.05,5.529,1.008,6.822,0
|
||||
c0.739-0.571-0.218-3.36,0.781-4.478c1.412-1.562,7.796-8.108,8.284-1.874c0.286,3.697-0.067,3.402,3.99,3.402
|
||||
c3.268,0-0.176-1.604,1.302-2.948c2.244-2.05,5-8.645,7.898-9.511c1.697-0.513,10.183,3.268,12.914,3.277
|
||||
c-0.95,2.73-1.874,5.478-2.865,8.2c4.267,2.16,12.258,8.788,17.223,5.906c2.318-1.352,3.268-13.014,3.949-15.745
|
||||
c2.881,4.453,7.83,7.755,9.519,12.09c2.579,6.604,6.864,6.83,10.729,12.233c3.604,5.016,6.906,9.864,9.906,15.199
|
||||
C297.757,412.423,296.564,413.717,293.849,421.691z M237.466,240.155c1.05,1.444-3.789,8.956-4.907,6.982
|
||||
c-0.772-1.37-0.982-4.73-1.554-6.428c-0.109-0.319-0.319-0.958,0-0.008C230.064,237.928,236.24,238.475,237.466,240.155z
|
||||
M267.989,202.381c3.991-0.008,4.168,5.125,8.604,3.772c-0.866,0.26,2.478,3.621-2.16,4.486c-2.916,0.529-6.965,3.73-10.166,1.63
|
||||
c0.428,0.286,0.218,0.151,0.008,0.016c1.95,1.311-3.47,2.412-3.671,0.89C260.604,213.202,270.182,202.372,267.989,202.381z
|
||||
M268.569,177.066c0,0.092,1.604-2.21,2.252-3.176c-0.706,2.824,6.435,16.526,4.335,17.173c-1.31,0.404-3.101-2.134-3.596-1.529
|
||||
c-2.209,2.697,1.74,6.83,0.479,7.973c0.319-0.286-2.958,0.723-3.772,1.193c0.017,0.152,0.042,0.236,0.067,0.236
|
||||
c-0.335,0-0.302-0.093-0.067-0.236C268.023,196.458,268.712,178.62,268.569,177.066z M291.959,363.442
|
||||
c-7.687,1.613-17.484-13.88-21.886-7.05c-3.151,4.89-16.854-2.201-17.409-0.083c0.782-2.992,5.84-0.933,2.579-5.294
|
||||
c-2.805-3.756-6.351-3.546-10.897-4.201c-3.251-0.47-2.352-2.453-3.772-4.268c-0.874-1.117-1.832,1.958-2.689,1.639
|
||||
c-0.118-0.042-4.848-6.982-4.848-7.209c5.864-7.688,7.494,2.764,10.771,4.1c6.612,2.697,6.704-4.73,13.098-1.806
|
||||
c3.251,1.487,27.7,10.418,26.877,12.493c-0.428,0.496-0.949,0.874-1.571,1.118C283.382,354.77,291.144,363.618,291.959,363.442z
|
||||
M239.138,239.726c0-0.009,0-0.009,0-0.017c0.907-2.848,8.679-3.907,7.301-0.756C245.725,240.592,238.088,243.12,239.138,239.726z
|
||||
M261.218,221.948c0.008-0.176,3.126-11.066,4.722-8.15c1.756,3.218,0.95,19.433-2.874,21.769
|
||||
c-0.991-0.084-1.352-0.597-1.076-1.538c0,2.05-13.325,5.05-13.14,5.234c-2.075-2.151,1.344-3.428-3.756-2.941
|
||||
c0.075,0-11.309,1.487-7.746-0.546c3.798-2.168,7.074-2.714,11.527-2.798c3.646-0.067,3.319-5.276,5.94-5.511
|
||||
c1.227-0.109,0.538,3.369,2.748,1.537C260.478,226.586,260.881,225.46,261.218,221.948z M292.53,351.729
|
||||
c-2,0.488-7.343-2.218-5.478-2.453c-1.646,0.201-0.588,0.067,0.017-0.009c3.285-0.412,7.612-1.697,10.217-1.966
|
||||
C299.899,347.041,293.539,351.486,292.53,351.729z M299.706,347.142c-0.513-0.538-0.908-1.134-1.16-1.806
|
||||
C296.354,341.832,303.705,348.604,299.706,347.142z M376.371,386.555c-0.924-0.143-1.252-0.672-0.991-1.58
|
||||
c0.984-2.823,3.059,1.033,3.386,1.37C377.968,386.412,377.169,386.488,376.371,386.555z M379.958,381.53
|
||||
c-0.865,0,1.924-3.042,3.588-1.395C385.26,381.824,380.295,381.446,379.958,381.53z M397.502,128.882
|
||||
c-4.588-0.865-3.108-4.159-6.713-4.462c-4.226-0.353-1.932,5.016-5.713,4c-13.897-3.73-0.63,5.469-4.882,9.612
|
||||
c0.428-0.412-7.335-1.093-8.712-0.622c-2.815,0.966-6.738,1.622-9.108,3.352c-3.193,2.336-6.385,4.672-9.578,6.999
|
||||
c-1.68,1.236-3.822-2.016-6.15-0.932c-2.731,1.269-8.78,0.513-10.595,1.697c-1.941,1.277-3.369,7.192-4.318,9.208
|
||||
c-3.756,8.007-8.108,25.087-19.727,26.222c-0.572-4.79-5.957-24.718,3.73-25.752c7.747-0.84,18.82-12.014,16.728-19.845
|
||||
c-7.771,0.311-3.847,4.739-7.183,6.235c-6.084,2.722-6.94-3.168-9.074-2.286c-6.377,2.655-6.974,2.454-8.284,8.755
|
||||
c-0.487,2.336-8.847,0.774-11.107,0.689c-6.436-0.227-22.358-2.588-27.138,0.605c-7.427,4.957-14.854,9.914-22.282,14.871
|
||||
c3.982,7.579,14.846,2.512,17.224,10.167c1.571,5.023-5.192,14.938-8.914,18.76c-3.664,3.772-7.335,7.545-11.006,11.318
|
||||
c-3.512,3.612-3.991-0.488-7.352,1.411c-5.218,2.941-5.201,11.897-12.57,12.14c3.117,4.109,3.681,7.016,4.058,11.847
|
||||
c0.378,4.89-3.243,4.487-8.007,6.192c-0.143-3.016-0.588-6.04,0-9.023c0.849-4.26-4.074-0.336-3.226-4.588
|
||||
c1.47-7.385-7.906-3.201-11.846-2.134c0.302-2.134-0.412-4.444,0-6.561c-4.067,2.294-8.132,4.595-12.199,6.889
|
||||
c3.688,3.185,6.284,7.276,11.838,4.587c0.571,4.075-3.126,4.537-6.814,6.562c5.142,3,5.436,9.83,6.948,14.779
|
||||
c1.932,6.285-2.252,8.41-6.645,13.561c-4.672,5.478-5.655,6.982-12.746,8.931c-3.932,1.092-7.864,2.176-11.796,3.26
|
||||
c-2,0.554-0.319,3.428-4.117,3.428c-0.982-6.73-8.847-1.386-11.258,1.512c-3.579,4.284,3.159,7.486,6.756,10.998
|
||||
c9.653,9.436-3.874,15.123-10.931,19.962c-2.563-6.772-10.234-8.67-12.208-14.106c-2.689,9.838-3.285,10.074,4.033,17.568
|
||||
c5.335,5.469,6.058,9.796,8.175,16.87c-4.31-1.966-9.377-2.907-11.073-7.142c-2.076-5.167-3.621-8.048-7.47-12.267
|
||||
c-3.965-4.352-4.134-17.256-6.225-23.231c-1.555,1.168-4.201,1.118-5.747,2.294c-1.16-4.436-4.217-16.913-9.687-18.694
|
||||
c-4.453-1.453-14.241,4.646-17.82,7.267c-4.721,3.47-15.476,8.226-15.636,14.09c-0.268,10.234-0.538,10.998-8.897,17.677
|
||||
c-8.007-11.67-12.108-21.239-14.359-34.775c-7.662,3.755-11.872-6.578-16.509-10.821c-2.151-1.957-6.16-2.151-10.461-1.966
|
||||
c-0.092-2.655-0.15-5.318-0.15-7.99c0-31.145,6.301-60.728,17.694-87.672c16.123-38.135,42.504-70.936,75.649-94.922
|
||||
c2.142-0.05,4.251,0.286,6.326,1.294c4.025,1.966,9.368-4.974,15.392-2.924c-2.142-8.746,18.668-9.174,13.997,0.328
|
||||
c4.537-1.336,8.486-3.184,13.208-2.487c7.746,1.126,5.578,2.403,4.529,9.368c-0.042,0.261-24.189,8.838-15.812,10.014
|
||||
c8.108,1.143,15.132-4.327,23.542-3.713c9.687,0.698,14.005,4.89,24.424,5.184c-4.159-9.99,12.728-2.789,17.946-1.638
|
||||
c-5.78,4.748,5.738,10.158,9.696,13.452c-0.143-7.301,14.871-5.973,21.6-5.629c3.706,0.194,1.983-6.638,6.57-5.839
|
||||
c6.226,1.084,12.443,2.167,18.668,3.251c5.252,0.916,8.015-0.344,13.148,2.403c3.512,1.882,1.143,6.394,5.948,5.882
|
||||
c3.42-0.362,11.595-2.806,14.048-0.362c3.655,3.638,1.597,7.814,7.596,9.805c1.352-6.663,12.871-6.612,18.66-4.924
|
||||
c2.68,0.79,14.972,10.544,10.41-1.311c10.871,2.21,21.734,4.411,32.606,6.621c4.26,0.866,4.352,4.192,7.956,5.848
|
||||
c2.344,1.076,8.856,0.748,12.2,1.966C413.851,126.9,403.425,130.008,397.502,128.882z M406.693,134.654
|
||||
c0.176-0.588,0.362-1.176,0.546-1.764c1.429-1.412,9.62-1.546,10.814,0.428C419.136,135.108,407.441,135.293,406.693,134.654z
|
||||
M437.107,168.185c4.974-4.864,9.897-9.754,15.081-14.425c-10.149,0.513-12.468,2.932-14.358-6.562
|
||||
c-2.151,1.31-4.31,2.621-6.461,3.932c-2.638-3.923-8.838-9.552-2.874-13.778c1.782-1.252,6.008,0.487,7.898-0.656
|
||||
c1.832-1.1,3.293-6.008,4.31-7.873c-5.176-1.84-5.31,2.723-9.334,2.63c-3.881-0.092-9.258-3.293-12.922-4.596
|
||||
c3.377-6.99,11.485-5.906,17.013-6.939c2.521,3.318,4.982,6.687,7.326,10.158c6.41,9.494,12.09,19.508,17.022,29.943
|
||||
C453.406,164.791,445.156,170.715,437.107,168.185z M453.18,278.096c-1.051,0.194-1.673-1.747-0.236-1.747
|
||||
C454.381,276.349,454.381,277.878,453.18,278.096z M457.011,283.785c-0.47-0.58-0.227-0.278-0.008-0.017
|
||||
c-2.243-2.739-1.663-6.209,2.403-4.352C461.498,280.356,458.43,285.499,457.011,283.785z"/>
|
||||
<path class="st0" d="M231.005,240.701v0.008C231.055,240.86,231.089,240.945,231.005,240.701z"/>
|
||||
<path class="st0" d="M287.07,349.268c-0.008,0-0.008,0.009-0.017,0.009C287.608,349.2,287.414,349.226,287.07,349.268z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,5 @@
|
||||
<?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 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.2799 6.40005L11.7399 15.94C10.7899 16.89 7.96987 17.33 7.33987 16.7C6.70987 16.07 7.13987 13.25 8.08987 12.3L17.6399 2.75002C17.8754 2.49308 18.1605 2.28654 18.4781 2.14284C18.7956 1.99914 19.139 1.92124 19.4875 1.9139C19.8359 1.90657 20.1823 1.96991 20.5056 2.10012C20.8289 2.23033 21.1225 2.42473 21.3686 2.67153C21.6147 2.91833 21.8083 3.21243 21.9376 3.53609C22.0669 3.85976 22.1294 4.20626 22.1211 4.55471C22.1128 4.90316 22.0339 5.24635 21.8894 5.5635C21.7448 5.88065 21.5375 6.16524 21.2799 6.40005V6.40005Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 4H6C4.93913 4 3.92178 4.42142 3.17163 5.17157C2.42149 5.92172 2 6.93913 2 8V18C2 19.0609 2.42149 20.0783 3.17163 20.8284C3.92178 21.5786 4.93913 22 6 22H17C19.21 22 20 20.2 20 18V13" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 288 KiB |
@@ -0,0 +1,796 @@
|
||||
: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 {
|
||||
visibility: visible;
|
||||
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_header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.channel_arrow {
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.channel_arrow--placeholder {
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* The whole right side of the row is the dblclick target */
|
||||
.channel_row_click {
|
||||
flex: 1;
|
||||
padding: 0.1rem 0.25rem 0.1rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hover highlight for whole row area (title + blank space) */
|
||||
.channel_row_click:hover {
|
||||
background-color: var(--channel-hover-bg, #222); /* pick your color */
|
||||
}
|
||||
|
||||
|
||||
/* still keep text non-selectable if desired */
|
||||
.channel_details {
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.channel {
|
||||
&_details {
|
||||
flex: 0 0 100%;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&_children {
|
||||
border-left: solid var(--line-color) var(--line-width);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-left: 5px;
|
||||
padding-left: 11px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat {
|
||||
&_panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&_history {
|
||||
overflow-y: auto;
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
&_message {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 16px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&_box_wrapper {
|
||||
padding: 16px;
|
||||
border-top: solid var(--line-color) var(--line-width);
|
||||
}
|
||||
|
||||
&_box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
|
||||
background-color: var(--light-bg-color);
|
||||
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
|
||||
padding-left: 8px;
|
||||
padding-right: 16px;
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
input {
|
||||
color: white;
|
||||
background-color: var(--light-bg-color);
|
||||
|
||||
font-size: larger;
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
border: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user_edit_button {
|
||||
background-color: oklch(0.53 0.1431 264.18);
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
||||
}
|
||||
}
|
||||
|
||||
.button_row {
|
||||
display: flex;
|
||||
gap: clamp(4px, 1vw, 10px);
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
min-height: 0;
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.connection_status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.user_info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
|
||||
.user_name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user_data {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle_button {
|
||||
padding: clamp(4px, 0.5vw, 8px);
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
|
||||
background-color: unset;
|
||||
|
||||
border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px);
|
||||
border-radius: clamp(4px, 0.8vw, 10px);
|
||||
color: rgb(255 255 255 / 50%);
|
||||
|
||||
transition: all 0.5s ease-in-out;
|
||||
|
||||
&.is_on {
|
||||
background-color: oklch(0.5 0.1381 21.71 / 20.12%);
|
||||
color: oklch(0.53 0.1505 21.71 / 89.38%);
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.server {
|
||||
&_grid {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
background-color: var(--bg-color);
|
||||
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
grid-template-areas:
|
||||
"tree chat"
|
||||
"control chat";
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
grid-template-rows: auto 1fr 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"tree"
|
||||
"control"
|
||||
"chat";
|
||||
}
|
||||
}
|
||||
|
||||
&_channel_box {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
grid-area: tree;
|
||||
}
|
||||
|
||||
&_chat_box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
grid-area: chat;
|
||||
border-left: solid var(--line-color) var(--line-width);
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
border-left: unset;
|
||||
border-top: solid var(--line-color) var(--line-width);
|
||||
}
|
||||
}
|
||||
|
||||
&_control_box {
|
||||
padding: clamp(6px, 0.8vw, 12px);
|
||||
margin: clamp(6px, 0.8vw, 12px);
|
||||
background-color: var(--light-bg-color);
|
||||
border-radius: clamp(6px, 1vw, 10px);
|
||||
overflow: hidden;
|
||||
grid-area: control;
|
||||
|
||||
display: flex;
|
||||
gap: clamp(4px, 0.8vw, 8px);
|
||||
flex-direction: column;
|
||||
|
||||
// Dynamic font sizing for control elements
|
||||
--control-icon-size: clamp(16px, 2.5vw, 30px);
|
||||
--control-text-size: clamp(12px, 2vw, 25px);
|
||||
--control-small-text-size: clamp(10px, 1.5vw, 20px);
|
||||
--user-icon-size: clamp(24px, 4vw, 45px);
|
||||
--toggle-icon-size: clamp(18px, 3vw, 35px);
|
||||
|
||||
.connection_status {
|
||||
.material-symbols-outlined {
|
||||
font-size: var(--control-icon-size);
|
||||
}
|
||||
.status_text {
|
||||
font-size: var(--control-text-size);
|
||||
}
|
||||
.channel_text {
|
||||
font-size: var(--control-small-text-size);
|
||||
}
|
||||
}
|
||||
|
||||
.user_edit_button {
|
||||
.material-symbols-outlined {
|
||||
font-size: var(--user-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.user_info {
|
||||
.user_name {
|
||||
font-size: var(--control-text-size);
|
||||
}
|
||||
.user_data {
|
||||
font-size: var(--control-small-text-size);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle_button {
|
||||
.material-symbols-outlined {
|
||||
font-size: var(--toggle-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login {
|
||||
max-width: 50vw;
|
||||
align-self: center;
|
||||
padding: 32px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--login-bg-color);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
input,
|
||||
button {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #b3c6b4;
|
||||
}
|
||||
|
||||
&_version {
|
||||
color: var(--txt-color);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
&_bttn {
|
||||
font-weight: bold;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
&_error {
|
||||
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
color: red;
|
||||
|
||||
pre {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
&_status {
|
||||
&.is_error {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.server-list-page h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login_version {
|
||||
font-size: 0.55em;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.server-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Rounded card */
|
||||
.server-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.server-card__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.65;
|
||||
filter: brightness(0) invert(0.8); /* light gray */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.server-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1; /* pushes the connect button to the far right */
|
||||
min-width: 0; /* prevents text overflow from breaking flex layout */
|
||||
}
|
||||
|
||||
.server-card__name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.server-card__address {
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
|
||||
.server-card__action {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.server-card__action img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(0.8); /* light gray */
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.server-card__action:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.server-card__action:hover img {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.server-card__action:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Add server — dashed outline style to distinguish from real cards */
|
||||
.add-server-btn {
|
||||
width: 100%;
|
||||
padding: 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.add-server-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.0);
|
||||
z-index: 999;
|
||||
animation: backdrop-fade-in 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.modal {
|
||||
pointer-events: auto;
|
||||
|
||||
/* Make this solid or nearly solid instead of see-through */
|
||||
background: #141414;
|
||||
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
|
||||
padding: 1.25rem 1.5rem 1.4rem;
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
animation: modal-pop-in 160ms ease-out forwards;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Form layout */
|
||||
|
||||
.modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-field label {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.modal-field input {
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.modal-field input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.modal-field input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.modal-field input:user-invalid,
|
||||
.modal-field--strict input:invalid {
|
||||
border-color: rgba(255, 90, 90, 0.85);
|
||||
box-shadow: 0 0 0 1px rgba(255, 90, 90, 0.45);
|
||||
}
|
||||
|
||||
.modal-field__error {
|
||||
display: none;
|
||||
font-size: 0.75rem;
|
||||
color: #ff8888;
|
||||
}
|
||||
|
||||
.modal-field:has(input:user-invalid) .modal-field__error,
|
||||
.modal-field--strict:has(input:invalid) .modal-field__error {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Actions row */
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Secondary button (Cancel) */
|
||||
|
||||
.modal-btn {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.modal-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-btn:active {
|
||||
transform: translateY(0) scale(0.97);
|
||||
}
|
||||
|
||||
/* Primary button (Save) */
|
||||
|
||||
.modal-btn--primary {
|
||||
background: rgba(67, 156, 255, 0.85);
|
||||
border-color: rgba(67, 156, 255, 1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.modal-btn--primary:hover {
|
||||
background: rgba(92, 174, 255, 0.95);
|
||||
border-color: rgba(135, 196, 255, 1);
|
||||
}
|
||||
|
||||
/* Delete button (danger) */
|
||||
|
||||
.modal-btn--danger {
|
||||
background: rgba(220, 60, 60, 0.85);
|
||||
border-color: rgba(220, 60, 60, 1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.modal-btn--danger:hover {
|
||||
background: rgba(240, 80, 80, 0.95);
|
||||
border-color: rgba(255, 120, 120, 1);
|
||||
}
|
||||
|
||||
.modal-actions__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Override mode username row */
|
||||
|
||||
.override-username-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.override-username-input {
|
||||
flex: 1;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.override-username-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.override-username-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Connect action button highlight */
|
||||
|
||||
.server-card__action--connect:hover {
|
||||
background: rgba(67, 156, 255, 0.3);
|
||||
border-color: rgba(67, 156, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Ping info on server card */
|
||||
|
||||
.server-card__ping {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.1rem;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Keyframes */
|
||||
|
||||
@keyframes backdrop-fade-in {
|
||||
from { background: rgba(0, 0, 0, 0.0); }
|
||||
to { background: rgba(0, 0, 0, 0.4); }
|
||||
}
|
||||
|
||||
@keyframes modal-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
|
||||
<!--
|
||||
Borrowed from https://github.com/irh/audio-app/blob/main/apps/dioxus/AndroidManifest.xml
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<uses-feature android:name="android.hardware.audio.low_latency" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.audio.output" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.audio.pro" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
|
||||
<application android:hasCode="true" android:supportsRtl="true" android:icon="@mipmap/ic_launcher"
|
||||
android:extractNativeLibs="true"
|
||||
android:allowNativeHeapPointerTagging="false"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity android:configChanges="orientation|screenLayout|screenSize|keyboardHidden" android:exported="true"
|
||||
android:label="@string/app_name" android:name="dev.dioxus.main.MainActivity">
|
||||
<meta-data android:name="android.app.lib_name" android:value="dioxusmain" />
|
||||
<meta-data android:name="android.app.func_name" android:value="ANativeActivity_onCreate" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
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);
|
||||
}
|
||||
})();
|
||||
@@ -1,641 +0,0 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use ordermap::OrderSet;
|
||||
use sir::{css, global_css};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{imp, CONFIG};
|
||||
|
||||
pub type ChannelId = u32;
|
||||
pub type UserId = u32;
|
||||
|
||||
pub enum ConnectionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Command {
|
||||
Connect {
|
||||
address: String,
|
||||
username: String,
|
||||
},
|
||||
SendChat {
|
||||
markdown: String,
|
||||
channels: Vec<ChannelId>,
|
||||
},
|
||||
SetMute {
|
||||
mute: bool,
|
||||
},
|
||||
SetDeaf {
|
||||
deaf: bool,
|
||||
},
|
||||
EnterChannel {
|
||||
channel: ChannelId,
|
||||
user: UserId,
|
||||
},
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
use Command::*;
|
||||
use ConnectionState::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ChannelState {
|
||||
pub name: String,
|
||||
pub children: OrderSet<ChannelId>,
|
||||
pub users: OrderSet<UserId>,
|
||||
pub parent: Option<ChannelId>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UserState {
|
||||
pub name: String,
|
||||
pub channel: ChannelId,
|
||||
pub deaf: bool,
|
||||
pub mute: bool,
|
||||
pub self_deaf: bool,
|
||||
pub self_mute: bool,
|
||||
}
|
||||
|
||||
impl UserState {
|
||||
pub fn icon(&self) -> UserIcon {
|
||||
match (self.mute || self.self_mute, self.deaf || self.self_deaf) {
|
||||
(false, false) => UserIcon::Normal,
|
||||
(true, false) => UserIcon::Muted,
|
||||
(_, true) => UserIcon::Deafened,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Chat {
|
||||
pub raw: String,
|
||||
pub dangerous_html: String,
|
||||
pub sender: Option<UserId>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ServerState {
|
||||
pub channels: HashMap<ChannelId, ChannelState>,
|
||||
pub users: HashMap<UserId, UserState>,
|
||||
pub chat: Vec<Chat>,
|
||||
pub session: Option<UserId>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn this_user(&self) -> Option<&UserState> {
|
||||
self.users.get(&self.session?)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub status: GlobalSignal<ConnectionState>,
|
||||
pub server: GlobalSignal<ServerState>,
|
||||
}
|
||||
|
||||
pub static STATE: State = State {
|
||||
status: Signal::global(|| Disconnected),
|
||||
server: Signal::global(|| Default::default()),
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UserIcon {
|
||||
Normal,
|
||||
Muted,
|
||||
Deafened,
|
||||
None,
|
||||
}
|
||||
|
||||
impl UserIcon {
|
||||
pub fn url(self) -> Option<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) -> Element {
|
||||
let pill = css!(
|
||||
"
|
||||
border-radius: 100px;
|
||||
padding: 4px 8px;
|
||||
width: fit-content;
|
||||
|
||||
img {
|
||||
height: 1em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let color = match icon {
|
||||
UserIcon::Normal => "var(--accent-a)",
|
||||
UserIcon::Muted => "var(--accent-b)",
|
||||
UserIcon::Deafened => "var(--accent-c)",
|
||||
UserIcon::None => "var(--accent-a)",
|
||||
};
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "{pill}",
|
||||
style: "background-color: {color}",
|
||||
{ icon.url().map(|url| rsx!(img { src: url })) }
|
||||
"\u{00A0}{name}\u{00A0}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn User(id: UserId) -> Element {
|
||||
let server = STATE.server.read();
|
||||
match server.users.get(&id) {
|
||||
Some(state) => rsx!(UserPill {
|
||||
name: state.name.clone(),
|
||||
icon: state.icon(),
|
||||
}),
|
||||
None => rsx!(UserPill {
|
||||
name: format!("unknown user ({id})"),
|
||||
icon: UserIcon::None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
};
|
||||
|
||||
let channel_details = css!(
|
||||
"
|
||||
flex: 0 0 100%;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
"
|
||||
);
|
||||
let channel_children = css!(
|
||||
"
|
||||
border-left: solid var(--line-color) var(--line-width);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-left: 5px;
|
||||
padding-left: 11px;
|
||||
padding-top: 4px; "
|
||||
);
|
||||
|
||||
rsx!(
|
||||
details {
|
||||
class: "{channel_details}",
|
||||
open: true,
|
||||
summary {
|
||||
span {
|
||||
role: "button",
|
||||
prevent_default: "onclick",
|
||||
ondoubleclick: move |evt| {
|
||||
evt.stop_propagation();
|
||||
net.send(EnterChannel { channel: id, user })
|
||||
},
|
||||
"{state.name}"
|
||||
}
|
||||
}
|
||||
if state.users.len() + state.children.len() > 0 {
|
||||
div {
|
||||
class: "{channel_children}",
|
||||
for id in state.users.iter() {
|
||||
User { id: *id }
|
||||
}
|
||||
for child in state.children.iter() {
|
||||
Channel { id: *child }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ChatView() -> Element {
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
let server = STATE.server.read();
|
||||
let mut draft = use_signal(|| "".to_string());
|
||||
|
||||
let chat_history = css!(
|
||||
"
|
||||
overflow-y: auto;
|
||||
flex: 1 0 0;
|
||||
"
|
||||
);
|
||||
|
||||
let chat_message = css!(
|
||||
"
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 16px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
"
|
||||
);
|
||||
|
||||
let chat_box = css!(
|
||||
"
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 16px;
|
||||
gap: 8px;
|
||||
border-top: solid var(--line-color) var(--line-width);
|
||||
|
||||
input {
|
||||
flex-grow: 1;
|
||||
padding: 8px;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let mut do_send = move || {
|
||||
if let Some(user) = STATE.server.read().this_user() {
|
||||
net.send(SendChat {
|
||||
markdown: draft.write().split_off(0),
|
||||
channels: vec![user.channel],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "{chat_history}",
|
||||
for chat in server.chat.iter() {
|
||||
div {
|
||||
class: "{chat_message}",
|
||||
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
|
||||
UserPill {
|
||||
name: sender.name.clone(),
|
||||
icon: UserIcon::None,
|
||||
}
|
||||
}
|
||||
span {
|
||||
dangerous_inner_html: "{chat.dangerous_html}",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "{chat_box}",
|
||||
input {
|
||||
placeholder: "say something",
|
||||
value: "{draft.read()}",
|
||||
oninput: move |evt| draft.set(evt.value().clone()),
|
||||
onkeypress: move |evt: Event<KeyboardData>| {
|
||||
if evt.code() == Code::Enter && evt.modifiers().is_empty() {
|
||||
do_send();
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
onclick: move |_| do_send(),
|
||||
"Send"
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ServerView() -> Element {
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
let server = STATE.server.read();
|
||||
let Some(&UserState {
|
||||
deaf,
|
||||
self_deaf,
|
||||
mute,
|
||||
self_mute,
|
||||
..
|
||||
}) = server.this_user()
|
||||
else {
|
||||
return rsx!();
|
||||
};
|
||||
|
||||
let grid = css!(
|
||||
r#"
|
||||
display: grid;
|
||||
height: 100%;
|
||||
background-color: var(--bg-color);
|
||||
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"bar bar"
|
||||
"tree chat";
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
grid-template-rows: auto 1fr 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"bar"
|
||||
"tree"
|
||||
"chat";
|
||||
}
|
||||
"#
|
||||
);
|
||||
|
||||
let channel_box = css!(
|
||||
"
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
grid-area: tree;
|
||||
"
|
||||
);
|
||||
|
||||
let chat_box = css!(
|
||||
"
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: chat;
|
||||
border-left: solid var(--line-color) var(--line-width);
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
border-left:unset;
|
||||
border-top: solid var(--line-color) var(--line-width);
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let top_bar = css!(
|
||||
"
|
||||
padding: 16px;
|
||||
grid-area: bar;
|
||||
background-color: var(--login-bg-color);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
padding: 8px;
|
||||
|
||||
img {
|
||||
height: 1em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "{grid}",
|
||||
div {
|
||||
class: "{top_bar}",
|
||||
button {
|
||||
onclick: move |_| net.send(Disconnect),
|
||||
"Disconnect"
|
||||
}
|
||||
button {
|
||||
role: "switch",
|
||||
aria_checked: mute || self_mute,
|
||||
disabled: mute,
|
||||
onclick: move |_| net.send(SetMute { mute: !self_mute }),
|
||||
match mute || self_mute {
|
||||
true => rsx!(img { src: asset!("assets/mic-off-svgrepo-com.svg") }),
|
||||
false => rsx!(img { src: asset!("assets/mic-svgrepo-com.svg") }),
|
||||
}
|
||||
"\u{00A0}Mute"
|
||||
}
|
||||
button {
|
||||
role: "switch",
|
||||
aria_checked: deaf || self_deaf,
|
||||
disabled: deaf,
|
||||
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
|
||||
match deaf || self_deaf {
|
||||
true => rsx!(img { src: asset!("assets/speaker-muted-svgrepo-com.svg") }),
|
||||
false => rsx!(img { src: asset!("assets/speaker-medium-svgrepo-com.svg") }),
|
||||
}
|
||||
"\u{00A0}Deafen"
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "{channel_box}",
|
||||
for (id, state) in server.channels.iter() {
|
||||
if state.parent.is_none() {
|
||||
Channel { id: *id }
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "{chat_box}",
|
||||
ChatView {}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LoginView() -> Element {
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
let default_address = CONFIG.proxy_url.as_deref().unwrap_or("");
|
||||
let mut address = use_signal(|| default_address.to_string());
|
||||
|
||||
let previous_username = imp::load_username();
|
||||
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
|
||||
|
||||
let error = css!(
|
||||
"
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
color: red;
|
||||
pre {
|
||||
color: black;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let login_box = css!(
|
||||
"
|
||||
max-width: 50vw;
|
||||
align-self: center;
|
||||
padding: 32px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--login-bg-color);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
input,button {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #b3c6b4;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let bttn = css!(
|
||||
"
|
||||
font-weight: bold;
|
||||
font-size: large;
|
||||
"
|
||||
);
|
||||
|
||||
let do_connect = move |_| {
|
||||
//let _ = set_default_username(&username.read());
|
||||
let _ = imp::set_default_username(&username.read());
|
||||
net.send(Connect {
|
||||
address: address.read().clone(),
|
||||
username: username.read().clone(),
|
||||
})
|
||||
};
|
||||
let status = &STATE.status;
|
||||
let bottom = match &*status.read() {
|
||||
Disconnected => rsx! {
|
||||
button {
|
||||
class: "{bttn}",
|
||||
onclick: do_connect.clone(),
|
||||
"Connect"
|
||||
}
|
||||
},
|
||||
Connecting => rsx! {
|
||||
div {
|
||||
class: "{bttn}",
|
||||
"Connecting..."
|
||||
}
|
||||
},
|
||||
Failed(msg) => rsx!(
|
||||
button {
|
||||
class: "{bttn}",
|
||||
onclick: do_connect.clone(),
|
||||
"Reconnect"
|
||||
}
|
||||
div {
|
||||
class: "{error}",
|
||||
"Failed to connect:"
|
||||
pre {
|
||||
"{msg}"
|
||||
}
|
||||
}
|
||||
),
|
||||
Connected => unreachable!(),
|
||||
};
|
||||
rsx!(
|
||||
div {
|
||||
class: "{login_box}",
|
||||
h1 {
|
||||
"Mumble Web"
|
||||
}
|
||||
input {
|
||||
placeholder: "username",
|
||||
value: "{username.read()}",
|
||||
autofocus: "true",
|
||||
oninput: move |evt| username.set(evt.value().clone()),
|
||||
}
|
||||
input {
|
||||
placeholder: "server address",
|
||||
value: "{address.read()}",
|
||||
autofocus: "true",
|
||||
oninput: move |evt| address.set(evt.value().clone()),
|
||||
}
|
||||
{bottom}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub fn app() -> Element {
|
||||
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
|
||||
|
||||
global_css!(
|
||||
"
|
||||
:root {
|
||||
--txt-color: white;
|
||||
--bg-color: #372f3a;
|
||||
--login-bg-color: #5d7680;
|
||||
--primary-btn-color: #7bad9f;
|
||||
--accent-a: #8eb29a;
|
||||
--accent-b: #6a9395;
|
||||
--accent-c: #464459;
|
||||
--line-width: 2px;
|
||||
--line-color: white;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
background-color: var(--bg-color);
|
||||
overflow: auto;
|
||||
color: var(--txt-color);
|
||||
|
||||
font-family: sans-serif;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
button {
|
||||
font-weight: bold;
|
||||
font-size: medium;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--txt-color);
|
||||
background-color: var(--primary-btn-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
input:focus,input:focus-visible {
|
||||
border: none;
|
||||
outline: solid var(--line-width) var(--accent-a);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
a:link {
|
||||
color: var(--accent-a);
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--accent-b);
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
rsx!(
|
||||
sir::AppStyle { }
|
||||
match *STATE.status.read() {
|
||||
Connected => rsx!(ServerView {}),
|
||||
_ => rsx!(LoginView {}),
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
#[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::*;
|
||||
@@ -1,439 +0,0 @@
|
||||
use crate::app::Command;
|
||||
use crate::CONFIG;
|
||||
use color_eyre::eyre::{bail, eyre, Error};
|
||||
use dioxus::prelude::*;
|
||||
use futures::{AsyncRead, AsyncWrite};
|
||||
use futures_channel::mpsc::UnboundedSender;
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use mumble_protocol::control::{ClientControlCodec, ControlPacket};
|
||||
use mumble_protocol::voice::{VoicePacket, VoicePacketPayload};
|
||||
use mumble_protocol::Serverbound;
|
||||
use mumble_web2_common::GuiConfig;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, error, info, instrument};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::js_sys::{Promise, Reflect, Uint8Array};
|
||||
use web_sys::AudioContext;
|
||||
use web_sys::AudioContextOptions;
|
||||
use web_sys::AudioData;
|
||||
use web_sys::AudioDecoder;
|
||||
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;
|
||||
use web_sys::{console, window};
|
||||
|
||||
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(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) => info!("created encoder worklet: {:?}", &node),
|
||||
Err(err) => error!("could not create encoder worklet: {err}"),
|
||||
}
|
||||
});
|
||||
|
||||
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")).ey()?;
|
||||
|
||||
// Create MediaStream from MediaStreamTrackGenerator
|
||||
let js_tracks = web_sys::js_sys::Array::new();
|
||||
js_tracks.push(&audio_stream_generator);
|
||||
let media_stream = MediaStream::new_with_tracks(&js_tracks).ey()?;
|
||||
|
||||
// Create MediaStreamAudioSourceNode
|
||||
let audio_source = audio_context
|
||||
.create_media_stream_source(&media_stream)
|
||||
.ey()?;
|
||||
// Connect output of audio_source to audio_context (browser audio)
|
||||
audio_source
|
||||
.connect_with_audio_node(&audio_context.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)>);
|
||||
|
||||
// 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.ey() {
|
||||
error!("write chunk ready error {:?}", e);
|
||||
}
|
||||
if let Err(e) = JsFuture::from(writer.write_with_chunk(&audio_data))
|
||||
.await
|
||||
.ey()
|
||||
{
|
||||
error!("write chunk error {:?}", e);
|
||||
};
|
||||
writer.release_lock();
|
||||
});
|
||||
}) {
|
||||
error!("error writing audio data {:?}", e);
|
||||
}
|
||||
}) 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 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()
|
||||
.ey()?
|
||||
.get_user_media_with_constraints(MediaStreamConstraints::new().audio(&JsValue::TRUE))
|
||||
.ey()?
|
||||
.into_future()
|
||||
.await
|
||||
.ey()?
|
||||
.dyn_into()
|
||||
.map_err(|e| JsError::new(&format!("not a stream: {e:?}")))
|
||||
.ey()?;
|
||||
|
||||
let options = WorkletOptions::new();
|
||||
Reflect::set(
|
||||
&options,
|
||||
&"processorOptions".into(),
|
||||
&wasm_bindgen::module(),
|
||||
)
|
||||
.ey()?;
|
||||
|
||||
let module = asset!("assets/rust_mic_worklet.js").to_string();
|
||||
info!("loading mic worklet from {module:?}");
|
||||
audio_context
|
||||
.audio_worklet()
|
||||
.ey()?
|
||||
.add_module_with_options(&module, &options)
|
||||
.ey()?
|
||||
.into_future()
|
||||
.await
|
||||
.ey()?;
|
||||
|
||||
let source = audio_context.create_media_stream_source(&stream).ey()?;
|
||||
let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet").ey()?;
|
||||
|
||||
let encoder_error: Closure<dyn FnMut(JsValue)> =
|
||||
Closure::new(|e| error!("error encoding audio {:?}", e));
|
||||
|
||||
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(
|
||||
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 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) => {
|
||||
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>,
|
||||
) -> Result<(), Error> {
|
||||
info!("Rust via WASM!");
|
||||
|
||||
let object = web_sys::js_sys::Object::new();
|
||||
|
||||
Reflect::set(
|
||||
&object,
|
||||
&JsValue::from_str("algorithm"),
|
||||
&JsValue::from_str("sha-256"),
|
||||
)
|
||||
.ey()?;
|
||||
|
||||
if let Some(server_hash) = &CONFIG.cert_hash {
|
||||
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
|
||||
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?;
|
||||
}
|
||||
|
||||
let array = web_sys::js_sys::Array::new();
|
||||
array.push(&object);
|
||||
|
||||
debug!("created option object: {:?}", &object);
|
||||
|
||||
let mut options = WebTransportOptions::new();
|
||||
options.set_server_certificate_hashes(&array);
|
||||
|
||||
debug!("created WebTransportOptions");
|
||||
console::log_1(&options.clone().into());
|
||||
|
||||
let transport = WebTransport::new_with_options(&address, &options).ey()?;
|
||||
debug!("created WebTransport connection object");
|
||||
console::log_1(&transport.clone().into());
|
||||
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(transport.ready())
|
||||
.await
|
||||
.ey()
|
||||
{
|
||||
bail!("could not connect to transport: {e}");
|
||||
}
|
||||
|
||||
info!("transport is ready");
|
||||
|
||||
let stream: WebTransportBidirectionalStream =
|
||||
wasm_bindgen_futures::JsFuture::from(transport.create_bidirectional_stream())
|
||||
.await
|
||||
.ey()?
|
||||
.into();
|
||||
|
||||
let wasm_stream_readable = wasm_streams::ReadableStream::from_raw(stream.readable().into());
|
||||
let wasm_stream_writable = wasm_streams::WritableStream::from_raw(stream.writable().into());
|
||||
|
||||
let read_codec = ClientControlCodec::new();
|
||||
let write_codec = ClientControlCodec::new();
|
||||
|
||||
let reader =
|
||||
asynchronous_codec::FramedRead::new(wasm_stream_readable.into_async_read(), read_codec);
|
||||
let writer =
|
||||
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
|
||||
|
||||
crate::network_loop(username, event_rx, reader, writer).await
|
||||
}
|
||||
|
||||
pub fn set_default_username(username: &str) -> Option<()> {
|
||||
web_sys::window()?
|
||||
.local_storage()
|
||||
.ok()??
|
||||
.set_item("username", username)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn load_username() -> Option<String> {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.local_storage()
|
||||
.ok()??
|
||||
.get_item("username")
|
||||
.ok()?
|
||||
}
|
||||
|
||||
fn load_config_from_window() -> Option<GuiConfig> {
|
||||
serde_wasm_bindgen::from_value(Reflect::get(window()?.as_ref(), &"config".into()).ok()?).ok()
|
||||
}
|
||||
|
||||
fn load_config_from_env() -> Option<GuiConfig> {
|
||||
serde_json::from_str(option_env!("MUMBLE_WEB2_GUI_CONFIG")?).ok()?
|
||||
}
|
||||
|
||||
pub fn load_config() -> Option<GuiConfig> {
|
||||
load_config_from_window().or_else(load_config_from_env)
|
||||
}
|
||||
|
||||
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
|
||||
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt_layer)
|
||||
.with(perf_layer)
|
||||
.init();
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
cert.pem
|
||||
key.pem
|
||||
bundle
|
||||
config.toml
|
||||
@@ -4,15 +4,27 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
color-eyre = "0.6.3"
|
||||
serde = { version = "1.0.214", features = ["derive"] }
|
||||
serde_json = "1.0.132"
|
||||
tokio = { version = "1.37.0", features = ["full"] }
|
||||
tokio-rustls = "^0.26"
|
||||
toml = "0.8.19"
|
||||
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.74.2", features = ["quinn", "eyre", "rustls", "serve-static", "logging"] }
|
||||
once_cell = "1.20.2"
|
||||
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, features = ["networking"] }
|
||||
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"] }
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
https_listen_address = "127.0.0.1:4433"
|
||||
http_listen_address = "127.0.0.1:8080"
|
||||
cert_path = "./cert.pem"
|
||||
key_path = "./key.pem"
|
||||
mumble_server_url = "voip.ohea.xyz:64738"
|
||||
gui_path = "../target/dx/mumble-web2-gui/release/web/public"
|
||||
|
||||
[gui]
|
||||
force_proxy = true
|
||||
proxy_url = "https://127.0.0.1:4433/proxy"
|
||||
# cert_hash = [...]
|
||||
@@ -1,14 +1,14 @@
|
||||
use color_eyre::eyre::{anyhow, Context, Error, Result};
|
||||
use mumble_web2_common::GuiConfig;
|
||||
use once_cell::sync::OnceCell;
|
||||
use color_eyre::eyre::{anyhow, bail, Context, Result};
|
||||
use mumble_web2_common::{ping_server, ProxyOverrides, ServerStatus};
|
||||
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;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
@@ -23,57 +23,29 @@ use tracing::Instrument;
|
||||
use tracing::{error, instrument};
|
||||
use tracing_subscriber::filter::LevelFilter;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
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: PathBuf,
|
||||
key_path: PathBuf,
|
||||
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: PathBuf,
|
||||
gui: Mutex<GuiConfig>,
|
||||
gui_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
static CONFIG: OnceCell<Config> = OnceCell::new();
|
||||
|
||||
#[handler]
|
||||
#[instrument]
|
||||
async fn serve_gui_index_html(req: &Request, res: &mut Response) {
|
||||
let config = CONFIG.get().unwrap();
|
||||
|
||||
// Load the HTML file
|
||||
let path = config.gui_path.join("index.html");
|
||||
let html = match fs::read_to_string(&path).await {
|
||||
Ok(content) => content,
|
||||
Err(err) => {
|
||||
error!("could not load {}: {:?}", path.display(), err);
|
||||
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Insert the script tag with configuration
|
||||
let modified_html = html.replace(
|
||||
"</head>",
|
||||
&format!(
|
||||
"<script>window.config = {}</script>\n</head>",
|
||||
serde_json::to_string(&config.gui).unwrap(),
|
||||
),
|
||||
);
|
||||
res.render(Text::Html(modified_html));
|
||||
}
|
||||
|
||||
#[handler]
|
||||
async fn redirect_to_gui(res: &mut Response) {
|
||||
res.render(Redirect::permanent("/gui"));
|
||||
}
|
||||
|
||||
async fn init_config() -> Result<()> {
|
||||
fn init_config() -> Result<Config> {
|
||||
let mut config: Config = toml::from_str(
|
||||
&fs::read_to_string("./config.toml")
|
||||
.await
|
||||
&std::fs::read_to_string("./config.toml")
|
||||
.context("reading config.toml (try making a copy of config.toml.example)")?,
|
||||
)?;
|
||||
let mumble_server_addr = config
|
||||
@@ -89,123 +61,192 @@ async fn init_config() -> Result<()> {
|
||||
config.mumble_server_url
|
||||
))?;
|
||||
config.mumble_server_address = Some(mumble_server_addr);
|
||||
CONFIG
|
||||
.set(config)
|
||||
.map_err(|_| anyhow!("config already initialized"))?;
|
||||
Ok(())
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
init_logging();
|
||||
init_config().await?;
|
||||
let config = CONFIG.get().unwrap();
|
||||
let server_config = Arc::new(init_config()?);
|
||||
info!("config:\n{}", toml::to_string_pretty(&*server_config)?);
|
||||
|
||||
// Server routing
|
||||
let router = Router::new()
|
||||
.get(redirect_to_gui)
|
||||
.push(Router::with_path("/proxy").goal(connect_proxy))
|
||||
.push(Router::with_path("/gui").get(serve_gui_index_html))
|
||||
.push(Router::with_path("/gui/<*+rest>").get(StaticDir::new(config.gui_path.clone())))
|
||||
// right now dioxus assets don't properly handle base_url, so we are stuck with this
|
||||
.push(
|
||||
Router::with_path("/assets/<*+rest>")
|
||||
.get(StaticDir::new(config.gui_path.join("assets"))),
|
||||
)
|
||||
.hoop(Logger::new());
|
||||
|
||||
// Read server certs
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?;
|
||||
let cert = fs::read(&config.cert_path)
|
||||
.await
|
||||
.context(format!("reading cert {}", config.cert_path.display()))?;
|
||||
let key = fs::read(&config.key_path)
|
||||
.await
|
||||
.context(format!("reading key {}", config.key_path.display()))?;
|
||||
|
||||
let mut overrides = ProxyOverrides {
|
||||
proxy_url: match &server_config.proxy_url {
|
||||
Some(url) => Some(url.to_string()),
|
||||
None => None,
|
||||
},
|
||||
cert_hash: None,
|
||||
any_server: false,
|
||||
};
|
||||
|
||||
let (cert, key) = match (&server_config.cert_path, &server_config.key_path) {
|
||||
(None, None) => {
|
||||
info!("generating self-signed cert");
|
||||
|
||||
// FIXME: redo every <14 days
|
||||
let mut dname = rcgen::DistinguishedName::new();
|
||||
dname.push(rcgen::DnType::CommonName, "mumble-web self-signed");
|
||||
let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?;
|
||||
let mut cert_params =
|
||||
rcgen::CertificateParams::new(server_config.cert_alt_names.clone())?;
|
||||
cert_params.distinguished_name = dname;
|
||||
cert_params.not_before = time::OffsetDateTime::now_utc();
|
||||
cert_params.not_after = cert_params.not_before + time::Duration::days(12);
|
||||
let cert = cert_params.self_signed(&key_pair)?;
|
||||
|
||||
let hash = hmac_sha256::Hash::hash(cert.der().as_ref());
|
||||
overrides.cert_hash = Some(hash.into());
|
||||
|
||||
(cert.pem().into(), key_pair.serialize_pem().into())
|
||||
}
|
||||
(Some(cert_path), Some(key_path)) => {
|
||||
// Read server certs
|
||||
let cert = fs::read(cert_path)
|
||||
.await
|
||||
.context(format!("reading cert {}", cert_path.display()))?;
|
||||
let key = fs::read(key_path)
|
||||
.await
|
||||
.context(format!("reading key {}", key_path.display()))?;
|
||||
(cert, key)
|
||||
}
|
||||
_ => {
|
||||
bail!("please supply both cert_path and key_path (or neither to generate a self-signed cert)")
|
||||
}
|
||||
};
|
||||
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
|
||||
|
||||
info!("proxy overrides:\n{}", toml::to_string_pretty(&overrides)?);
|
||||
|
||||
let config_craft = ConfigCraft {
|
||||
server_config: server_config.clone(),
|
||||
overrides,
|
||||
};
|
||||
|
||||
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("/overrides").get(config_craft.get_overrides()))
|
||||
.push(Router::with_path("/status").get(status_craft.get_status()))
|
||||
.hoop(Logger::new());
|
||||
if let Some(gui_path) = server_config.gui_path.clone() {
|
||||
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 = config.http_listen_address.map(TcpListener::new);
|
||||
let http_listener = server_config.http_listen_address.map(TcpListener::new);
|
||||
let https_listener =
|
||||
TcpListener::new(config.https_listen_address).rustls(rustls_config.clone());
|
||||
let http3_listener = QuinnListener::new(rustls_config, config.https_listen_address);
|
||||
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(router).await;
|
||||
Server::new(accepter).serve(service).await;
|
||||
}
|
||||
(None, b, c) => {
|
||||
let accepter = b.join(c).bind().await;
|
||||
Server::new(accepter).serve(router).await;
|
||||
Server::new(accepter).serve(service).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[handler]
|
||||
#[instrument]
|
||||
async fn connect_proxy(req: &mut Request, res: &mut Response) {
|
||||
info!("received proxy request");
|
||||
let mumble_server_address = CONFIG.get().unwrap().mumble_server_address.unwrap();
|
||||
#[derive(Clone)]
|
||||
pub struct StatusCraft {
|
||||
mumble_server_address: SocketAddr,
|
||||
}
|
||||
|
||||
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;
|
||||
#[craft]
|
||||
impl StatusCraft {
|
||||
#[craft(handler)]
|
||||
async fn get_status(&self) -> Json<ServerStatus> {
|
||||
let addr = self.mumble_server_address;
|
||||
match ping_server(&addr.ip().to_string(), addr.port()).await {
|
||||
Ok(status) => Json(status),
|
||||
Err(e) => {
|
||||
error!("ping failed: {e:#}");
|
||||
Json(ServerStatus::default())
|
||||
}
|
||||
}
|
||||
};
|
||||
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;
|
||||
}
|
||||
};
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigCraft {
|
||||
server_config: Arc<Config>,
|
||||
overrides: ProxyOverrides,
|
||||
}
|
||||
|
||||
/*
|
||||
let id = wt.session_id();
|
||||
let bi = match wt.open_bi(id).await {
|
||||
Ok(bi) => bi,
|
||||
Err(err) => {
|
||||
res.status_code(StatusCode::BAD_REQUEST);
|
||||
res.render(format!("could not open bidirectional stream: {err:?}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
*/
|
||||
#[craft]
|
||||
impl ConfigCraft {
|
||||
#[craft(handler)]
|
||||
async fn get_overrides(&self) -> Json<ProxyOverrides> {
|
||||
Json(self.overrides.clone())
|
||||
}
|
||||
|
||||
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:?}")
|
||||
#[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:?}");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if let Err(err) = res {
|
||||
error!("crash in connected proxy {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,19 +273,13 @@ async fn connect_proxy_impl(
|
||||
|
||||
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")),
|
||||
);
|
||||
|
||||
// Handle transmitting data between the WebTransport client and Mumble TCP Server
|
||||
// When one direction completes/fails, the other is dropped and its streams are closed
|
||||
tokio::select! {
|
||||
res = c2s => res??,
|
||||
res = s2c => res??,
|
||||
res = pass_bytes_loop(incoming, write_server)
|
||||
.instrument(info_span!("Handler", "Client to server")) => res?,
|
||||
res = pass_bytes_loop(read_server, outgoing)
|
||||
.instrument(info_span!("Handler", "Server to client")) => res?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||