Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4131955d1a | |||
| 2c5af5e301 | |||
| 32892f8062 | |||
| 614c7f7d56 | |||
| 5156338eb3 | |||
| 451ccf42ac | |||
| c678de4921 | |||
| 25ec34d7e7 | |||
| bc20cf825d | |||
| e72bb6d4c4 | |||
| 63ce666fa7 | |||
| 3a9bb60605 | |||
| 7f35a216cd | |||
| f0ce15000e | |||
| 7337b3e49b | |||
| d67a19c478 | |||
| 518c50d8a4 | |||
| 847c636f41 |
@@ -0,0 +1 @@
|
|||||||
|
target
|
||||||
@@ -42,6 +42,47 @@ jobs:
|
|||||||
path: target/release/mumble-web2-proxy
|
path: target/release/mumble-web2-proxy
|
||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
|
macos_build:
|
||||||
|
runs-on: macos
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Restore Rust cache
|
||||||
|
uses: actions/cache/restore@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo
|
||||||
|
./target
|
||||||
|
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
rust-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install cargo binstall
|
||||||
|
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
|
||||||
|
|
||||||
|
- name: Install dioxus-cli
|
||||||
|
run: cargo binstall dioxus-cli --version 0.7.3 --no-confirm
|
||||||
|
|
||||||
|
- name: Build dioxus project
|
||||||
|
run: dx bundle --platform macos --release -p mumble-web2-gui
|
||||||
|
|
||||||
|
- name: Save Rust cache
|
||||||
|
if: always()
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo
|
||||||
|
./target
|
||||||
|
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Upload mumble-web2-gui Artifact
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mumble-web2-gui-macos-arm64
|
||||||
|
path: gui/dist
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
windows_build:
|
windows_build:
|
||||||
runs-on: windows
|
runs-on: windows
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+1
-1
@@ -6,4 +6,4 @@ server_hash.txt
|
|||||||
proxy/bundle
|
proxy/bundle
|
||||||
/config.toml
|
/config.toml
|
||||||
proxy/config.toml
|
proxy/config.toml
|
||||||
gui/assets/*_onnx.tar.gz
|
*_onnx.tar.gz
|
||||||
|
|||||||
Generated
+26
-14
@@ -1587,9 +1587,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dioxus-core-types"
|
name = "dioxus-core-types"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f"
|
checksum = "b99d7d199aad72431b549759550002e7d72c8a257eba500dca9fbdb2122de103"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dioxus-desktop"
|
name = "dioxus-desktop"
|
||||||
@@ -4220,14 +4220,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mumble-web2-common"
|
name = "mumble-web2-client"
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mumble-web2-gui"
|
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-permissions",
|
"android-permissions",
|
||||||
@@ -4241,9 +4234,8 @@ dependencies = [
|
|||||||
"crossbeam-queue",
|
"crossbeam-queue",
|
||||||
"dasp_ring_buffer",
|
"dasp_ring_buffer",
|
||||||
"deep_filter",
|
"deep_filter",
|
||||||
"dioxus",
|
|
||||||
"dioxus-asset-resolver",
|
"dioxus-asset-resolver",
|
||||||
"dioxus-web",
|
"dioxus-signals",
|
||||||
"etcetera",
|
"etcetera",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
@@ -4252,6 +4244,7 @@ dependencies = [
|
|||||||
"jni",
|
"jni",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"lol_html 2.7.0",
|
"lol_html 2.7.0",
|
||||||
|
"manganis",
|
||||||
"markdown",
|
"markdown",
|
||||||
"merge-io",
|
"merge-io",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
@@ -4263,7 +4256,6 @@ dependencies = [
|
|||||||
"opus",
|
"opus",
|
||||||
"ordermap",
|
"ordermap",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rfd 0.16.0",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde-wasm-bindgen",
|
"serde-wasm-bindgen",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4279,6 +4271,27 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mumble-web2-common"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"color-eyre",
|
||||||
|
"serde",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mumble-web2-gui"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"color-eyre",
|
||||||
|
"dioxus",
|
||||||
|
"dioxus-web",
|
||||||
|
"mumble-web2-client",
|
||||||
|
"mumble-web2-common",
|
||||||
|
"rfd 0.16.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mumble-web2-proxy"
|
name = "mumble-web2-proxy"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4287,7 +4300,6 @@ dependencies = [
|
|||||||
"hmac-sha256",
|
"hmac-sha256",
|
||||||
"mumble-web2-common",
|
"mumble-web2-common",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand 0.9.2",
|
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
"salvo",
|
"salvo",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["common", "gui", "proxy"]
|
members = ["client", "common", "gui", "proxy"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
serde = { version = "1.0.214", features = ["derive"] }
|
serde = { version = "1.0.214", features = ["derive"] }
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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>;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam::atomic::AtomicCell;
|
||||||
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
|
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
|
||||||
use df::tract::{DfParams, DfTract, RuntimeParams};
|
use df::tract::{DfParams, DfTract, RuntimeParams};
|
||||||
use dioxus::prelude::{asset, manganis, Asset};
|
|
||||||
use dioxus_asset_resolver::read_asset_bytes;
|
use dioxus_asset_resolver::read_asset_bytes;
|
||||||
|
use manganis::{asset, Asset};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
@@ -44,11 +44,12 @@ fn with_denoising_model<O>(spawn: &SpawnHandle, func: impl FnOnce(&mut DfTract)
|
|||||||
let cell = Arc::new(AtomicCell::new(None));
|
let cell = Arc::new(AtomicCell::new(None));
|
||||||
let cell_task = cell.clone();
|
let cell_task = cell.clone();
|
||||||
*state = DenoisingModelState::Downloading(cell);
|
*state = DenoisingModelState::Downloading(cell);
|
||||||
|
let model = DF_MODEL.to_string();
|
||||||
spawn.spawn(async move {
|
spawn.spawn(async move {
|
||||||
let model_bytes = match read_asset_bytes(&DF_MODEL).await {
|
let model_bytes = match read_asset_bytes(&model).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("could not read denoising model from \"{DF_MODEL}\": {e:?}");
|
error!("could not read denoising model from \"{model}\": {e:?}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -96,20 +97,9 @@ pub struct AudioProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AudioProcessor {
|
impl AudioProcessor {
|
||||||
pub fn new_plain() -> Self {
|
pub fn new(denoise: bool) -> Self {
|
||||||
AudioProcessor {
|
AudioProcessor {
|
||||||
denoise: false,
|
denoise,
|
||||||
spawn: SpawnHandle::current(),
|
|
||||||
buffer: Vec::new(),
|
|
||||||
noise_floor: DEFAULT_NOISE_FLOOR,
|
|
||||||
was_transmitting: false,
|
|
||||||
hold_samples: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_denoising() -> Self {
|
|
||||||
AudioProcessor {
|
|
||||||
denoise: true,
|
|
||||||
spawn: SpawnHandle::current(),
|
spawn: SpawnHandle::current(),
|
||||||
buffer: Vec::new(),
|
buffer: Vec::new(),
|
||||||
noise_floor: DEFAULT_NOISE_FLOOR,
|
noise_floor: DEFAULT_NOISE_FLOOR,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::app::Command;
|
use crate::app::{Command, ConnectTarget, SharedState};
|
||||||
use color_eyre::eyre::{bail, Error};
|
use color_eyre::eyre::{bail, Error};
|
||||||
use dioxus::hooks::UnboundedReceiver;
|
use futures_channel::mpsc::UnboundedReceiver;
|
||||||
use mumble_protocol::control::ClientControlCodec;
|
use mumble_protocol::control::ClientControlCodec;
|
||||||
use std::net::ToSocketAddrs;
|
use std::net::ToSocketAddrs;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -8,13 +8,13 @@ use tokio::net::TcpStream;
|
|||||||
use tokio_rustls::rustls;
|
use tokio_rustls::rustls;
|
||||||
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
|
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
|
||||||
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||||
use tokio_rustls::rustls::ClientConfig as RlsClientConfig;
|
use tokio_rustls::rustls::ClientConfig;
|
||||||
use tokio_rustls::rustls::DigitallySignedStruct;
|
use tokio_rustls::rustls::DigitallySignedStruct;
|
||||||
use tokio_rustls::TlsConnector;
|
use tokio_rustls::TlsConnector;
|
||||||
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
|
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
|
||||||
use tracing::{info, instrument};
|
use tracing::{info, instrument};
|
||||||
|
|
||||||
use mumble_web2_common::{ClientConfig, ServerStatus};
|
use mumble_web2_common::ProxyOverrides;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct NoCertificateVerification;
|
struct NoCertificateVerification;
|
||||||
@@ -70,29 +70,36 @@ impl ServerCertVerifier for NoCertificateVerification {
|
|||||||
|
|
||||||
#[instrument]
|
#[instrument]
|
||||||
pub async fn network_connect(
|
pub async fn network_connect(
|
||||||
address: String,
|
target: ConnectTarget,
|
||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
gui_config: &ClientConfig,
|
overrides: &ProxyOverrides,
|
||||||
|
state: SharedState,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
info!("connecting");
|
info!("connecting");
|
||||||
|
|
||||||
let config = RlsClientConfig::builder()
|
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()
|
.dangerous()
|
||||||
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
||||||
.with_no_client_auth();
|
.with_no_client_auth();
|
||||||
|
|
||||||
let connector = TlsConnector::from(Arc::new(config));
|
let connector = TlsConnector::from(Arc::new(config));
|
||||||
|
|
||||||
let addr = format!("{}:{}", address, 64738)
|
let addr = (&*host, port)
|
||||||
.to_socket_addrs()?
|
.to_socket_addrs()?
|
||||||
.next()
|
.next()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let server_tcp = TcpStream::connect(addr).await?;
|
let server_tcp = TcpStream::connect(addr).await?;
|
||||||
let server_stream = connector
|
let server_stream = connector
|
||||||
//.connect("127.0.0.1".try_into()?, server_tcp)
|
.connect(host.try_into()?, server_tcp)
|
||||||
.connect(address.try_into()?, server_tcp)
|
|
||||||
.await?;
|
.await?;
|
||||||
let (read_server, write_server) = tokio::io::split(server_stream);
|
let (read_server, write_server) = tokio::io::split(server_stream);
|
||||||
|
|
||||||
@@ -102,11 +109,9 @@ pub async fn network_connect(
|
|||||||
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
|
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
|
||||||
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_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 async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
|
|
||||||
bail!("status not supported on desktop yet")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@
|
|||||||
//! The traits make the platform boundary explicit and provide compile-time verification.
|
//! The traits make the platform boundary explicit and provide compile-time verification.
|
||||||
#![allow(async_fn_in_trait)]
|
#![allow(async_fn_in_trait)]
|
||||||
|
|
||||||
use crate::{app::Command, effects::AudioProcessor};
|
use crate::app::{Command, ConnectTarget, SharedState};
|
||||||
|
use crate::effects::AudioProcessor;
|
||||||
use color_eyre::eyre::Error;
|
use color_eyre::eyre::Error;
|
||||||
use dioxus::hooks::UnboundedReceiver;
|
use futures_channel::mpsc::UnboundedReceiver;
|
||||||
use mumble_web2_common::{ClientConfig, ServerStatus};
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -50,11 +52,24 @@ pub trait AudioPlayerInterface {
|
|||||||
fn play_opus(&mut self, payload: &[u8]);
|
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
|
/// This is the main trait that each platform must implement. It combines all
|
||||||
/// platform-specific functionality into a single interface, providing compile-time
|
/// platform-specific functionality into a single interface, providing compile-time
|
||||||
/// verification that all platforms implement the required functionality.
|
/// verification that all platforms implement the required functionality.
|
||||||
pub trait PlatformInterface {
|
pub trait PlatformInterface {
|
||||||
type AudioSystem: AudioSystemInterface;
|
type AudioSystem: AudioSystemInterface;
|
||||||
|
type ConfigSystem: ConfigSystemInterface;
|
||||||
|
|
||||||
/// Initialize logging for the platform.
|
/// Initialize logging for the platform.
|
||||||
fn init_logging();
|
fn init_logging();
|
||||||
@@ -64,31 +79,25 @@ pub trait PlatformInterface {
|
|||||||
|
|
||||||
/// Establish a connection to the Mumble server and run the network loop.
|
/// Establish a connection to the Mumble server and run the network loop.
|
||||||
fn network_connect(
|
fn network_connect(
|
||||||
address: String,
|
target: ConnectTarget,
|
||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
gui_config: &ClientConfig,
|
proxy_overrides: &ProxyOverrides,
|
||||||
|
state: SharedState,
|
||||||
) -> impl Future<Output = Result<(), Error>>;
|
) -> impl Future<Output = Result<(), Error>>;
|
||||||
|
|
||||||
/// Get server status (user count, version, etc.).
|
/// 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(
|
fn get_status(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
|
address: &str,
|
||||||
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
|
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
|
||||||
|
|
||||||
/// Load the proxy overrides (proxy URL, cert hash, etc.).
|
/// Load the proxy overrides (proxy URL, cert hash, etc.).
|
||||||
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>>;
|
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>>;
|
||||||
|
|
||||||
/// Load saved username.
|
|
||||||
fn load_username() -> Option<String>;
|
|
||||||
|
|
||||||
/// Load saved server URL.
|
|
||||||
fn load_server_url() -> Option<String>;
|
|
||||||
|
|
||||||
/// Save the default username.
|
|
||||||
fn set_default_username(username: &str) -> Option<()>;
|
|
||||||
|
|
||||||
/// Save the default server URL.
|
|
||||||
fn set_default_server(server: &str) -> Option<()>;
|
|
||||||
|
|
||||||
/// Async sleep for the given duration.
|
/// Async sleep for the given duration.
|
||||||
fn sleep(duration: Duration) -> impl Future<Output = ()>;
|
fn sleep(duration: Duration) -> impl Future<Output = ()>;
|
||||||
@@ -98,15 +107,21 @@ pub trait PlatformInterface {
|
|||||||
// Platform Modules
|
// Platform Modules
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
mod stub;
|
||||||
|
|
||||||
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
||||||
mod connect;
|
mod connect;
|
||||||
#[cfg(feature = "desktop")]
|
|
||||||
mod desktop;
|
|
||||||
#[cfg(feature = "mobile")]
|
|
||||||
mod mobile;
|
|
||||||
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
||||||
mod native_audio;
|
mod native_audio;
|
||||||
mod stub;
|
#[cfg(any(feature = "desktop", feature = "mobile"))]
|
||||||
|
mod native_config;
|
||||||
|
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
mod desktop;
|
||||||
|
|
||||||
|
#[cfg(feature = "mobile")]
|
||||||
|
mod mobile;
|
||||||
|
|
||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
@@ -133,6 +148,8 @@ pub type Platform = stub::StubPlatform;
|
|||||||
pub type AudioSystem = <Platform as PlatformInterface>::AudioSystem;
|
pub type AudioSystem = <Platform as PlatformInterface>::AudioSystem;
|
||||||
pub type AudioPlayer = <AudioSystem as AudioSystemInterface>::AudioPlayer;
|
pub type AudioPlayer = <AudioSystem as AudioSystemInterface>::AudioPlayer;
|
||||||
|
|
||||||
|
pub type ConfigSystem = <Platform as PlatformInterface>::ConfigSystem;
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Platform Async Runtime
|
// Platform Async Runtime
|
||||||
// ========================
|
// ========================
|
||||||
@@ -164,3 +181,12 @@ const _: () = {
|
|||||||
let _ = assert_platform::<mobile::MobilePlatform>;
|
let _ = assert_platform::<mobile::MobilePlatform>;
|
||||||
let _ = assert_platform::<stub::StubPlatform>;
|
let _ = assert_platform::<stub::StubPlatform>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn global_default_config() -> HashMap<String, serde_json::Value> {
|
||||||
|
serde_json::json!({})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -112,7 +112,7 @@ impl super::AudioSystemInterface for NativeAudioSystem {
|
|||||||
);
|
);
|
||||||
let mut encoder =
|
let mut encoder =
|
||||||
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
|
opus::Encoder::new(SAMPLE_RATE, opus::Channels::Mono, opus::Application::Voip)?;
|
||||||
let mut current_processor = AudioProcessor::new_plain();
|
let mut current_processor = AudioProcessor::new(false);
|
||||||
let mut output_buffer = Vec::new();
|
let mut output_buffer = Vec::new();
|
||||||
let processors = self.processors.clone();
|
let processors = self.processors.clone();
|
||||||
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
|
let error_callback = move |e: cpal::StreamError| error!("error recording: {e:?}");
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
/// Stub implementation of the platform interface, so that we can
|
/// Stub implementation of the platform interface, so that we can
|
||||||
/// `cargo check` without any --feature flags.
|
/// `cargo check` without any --feature flags.
|
||||||
use crate::effects::AudioProcessor;
|
use crate::{
|
||||||
|
app::{ConnectTarget, SharedState},
|
||||||
|
effects::AudioProcessor,
|
||||||
|
};
|
||||||
use color_eyre::eyre::Error;
|
use color_eyre::eyre::Error;
|
||||||
use dioxus::hooks::UnboundedReceiver;
|
use futures_channel::mpsc::UnboundedReceiver;
|
||||||
use mumble_web2_common::{ClientConfig, ServerStatus};
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|
||||||
pub struct StubPlatform;
|
pub struct StubPlatform;
|
||||||
|
|
||||||
impl super::PlatformInterface for StubPlatform {
|
impl super::PlatformInterface for StubPlatform {
|
||||||
type AudioSystem = StubAudioSystem;
|
type AudioSystem = StubAudioSystem;
|
||||||
|
type ConfigSystem = StubConfigSystem;
|
||||||
|
|
||||||
fn init_logging() {
|
fn init_logging() {
|
||||||
panic!("stubbed platform")
|
panic!("stubbed platform")
|
||||||
@@ -20,40 +24,26 @@ impl super::PlatformInterface for StubPlatform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn network_connect(
|
fn network_connect(
|
||||||
_address: String,
|
_target: ConnectTarget,
|
||||||
_username: String,
|
_username: String,
|
||||||
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
|
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
|
||||||
_gui_config: &ClientConfig,
|
_overrides: &ProxyOverrides,
|
||||||
|
_state: SharedState,
|
||||||
) -> impl Future<Output = Result<(), Error>> {
|
) -> impl Future<Output = Result<(), Error>> {
|
||||||
async { panic!("stubbed platform") }
|
async { panic!("stubbed platform") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_status(
|
fn get_status(
|
||||||
_client: &reqwest::Client,
|
_client: &reqwest::Client,
|
||||||
|
_address: &str,
|
||||||
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
|
) -> impl Future<Output = color_eyre::Result<ServerStatus>> {
|
||||||
async { panic!("stubbed platform") }
|
async { panic!("stubbed platform") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>> {
|
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>> {
|
||||||
async { panic!("stubbed platform") }
|
async { panic!("stubbed platform") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_username() -> Option<String> {
|
|
||||||
panic!("stubbed platform")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_server_url() -> Option<String> {
|
|
||||||
panic!("stubbed platform")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_default_username(_username: &str) -> Option<()> {
|
|
||||||
panic!("stubbed platform")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_default_server(_server: &str) -> Option<()> {
|
|
||||||
panic!("stubbed platform")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
|
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
|
||||||
async { panic!("stubbed platform") }
|
async { panic!("stubbed platform") }
|
||||||
}
|
}
|
||||||
@@ -92,6 +82,29 @@ impl super::AudioPlayerInterface for StubAudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[allow(unused)]
|
||||||
pub struct SpawnHandle;
|
pub struct SpawnHandle;
|
||||||
|
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
use crate::app::Command;
|
use crate::app::{Command, ConnectTarget, SharedState};
|
||||||
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
|
use crate::effects::{AudioProcessor, AudioProcessorSender, TransmitState};
|
||||||
use color_eyre::eyre::{bail, eyre, Error};
|
use color_eyre::eyre::{bail, eyre, Error};
|
||||||
use crossbeam::atomic::AtomicCell;
|
use crossbeam::atomic::AtomicCell;
|
||||||
use dioxus::prelude::*;
|
use futures_channel::mpsc::UnboundedReceiver;
|
||||||
use gloo_timers::future::TimeoutFuture;
|
use gloo_timers::future::TimeoutFuture;
|
||||||
use js_sys::Float32Array;
|
use js_sys::Float32Array;
|
||||||
|
use manganis::asset;
|
||||||
use mumble_protocol::control::ClientControlCodec;
|
use mumble_protocol::control::ClientControlCodec;
|
||||||
use mumble_web2_common::{ClientConfig, ServerStatus};
|
use mumble_web2_common::{ProxyOverrides, ServerStatus};
|
||||||
use reqwest::Url;
|
use reqwest::Url;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -62,6 +64,7 @@ pub struct WebPlatform;
|
|||||||
|
|
||||||
impl super::PlatformInterface for WebPlatform {
|
impl super::PlatformInterface for WebPlatform {
|
||||||
type AudioSystem = WebAudioSystem;
|
type AudioSystem = WebAudioSystem;
|
||||||
|
type ConfigSystem = WebConfigSystem;
|
||||||
|
|
||||||
fn init_logging() {
|
fn init_logging() {
|
||||||
// copied from tracing_web example usage
|
// copied from tracing_web example usage
|
||||||
@@ -89,56 +92,41 @@ impl super::PlatformInterface for WebPlatform {
|
|||||||
// No-op on web
|
// No-op on web
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_config() -> color_eyre::Result<ClientConfig> {
|
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
|
||||||
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
|
let overrides = match option_env!("MUMBLE_WEB2_PROXY_OVERRIDES_URL") {
|
||||||
Some(url) => Url::parse(url)?,
|
Some(url) => Url::parse(url)?,
|
||||||
None => absolute_url("config")?,
|
None => absolute_url("overrides")?,
|
||||||
};
|
};
|
||||||
info!("loading config from {}", config_url);
|
info!("loading config from {}", overrides);
|
||||||
|
|
||||||
let config = reqwest::get(config_url)
|
let config = reqwest::get(overrides)
|
||||||
.await?
|
.await?
|
||||||
.json::<ClientConfig>()
|
.json::<ProxyOverrides>()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_username() -> Option<String> {
|
|
||||||
web_sys::window()
|
|
||||||
.unwrap()
|
|
||||||
.local_storage()
|
|
||||||
.ok()??
|
|
||||||
.get_item("username")
|
|
||||||
.ok()?
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_server_url() -> Option<String> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_default_username(username: &str) -> Option<()> {
|
|
||||||
web_sys::window()?
|
|
||||||
.local_storage()
|
|
||||||
.ok()??
|
|
||||||
.set_item("username", username)
|
|
||||||
.ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_default_server(_server: &str) -> Option<()> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn network_connect(
|
async fn network_connect(
|
||||||
address: String,
|
target: ConnectTarget,
|
||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
gui_config: &ClientConfig,
|
overrides: &ProxyOverrides,
|
||||||
|
state: SharedState,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
network_connect(address, username, event_rx, gui_config).await
|
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) -> color_eyre::Result<ServerStatus> {
|
async fn get_status(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
_address: &str,
|
||||||
|
) -> color_eyre::Result<ServerStatus> {
|
||||||
Ok(client
|
Ok(client
|
||||||
.get(absolute_url("status")?)
|
.get(absolute_url("status")?)
|
||||||
.send()
|
.send()
|
||||||
@@ -179,7 +167,7 @@ pub struct WebAudioSystem {
|
|||||||
processors: AudioProcessorSender,
|
processors: AudioProcessorSender,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
|
async fn attach_worklet(audio_context: &AudioContext, worklet_url: &str) -> Result<(), Error> {
|
||||||
// Create worklets to process mic and speaker audio
|
// Create worklets to process mic and speaker audio
|
||||||
// Speaker audio processing worklet only required on
|
// Speaker audio processing worklet only required on
|
||||||
// browsers that don't support MediaStreamTrackGenerator
|
// browsers that don't support MediaStreamTrackGenerator
|
||||||
@@ -192,12 +180,11 @@ async fn attach_worklet(audio_context: &AudioContext) -> Result<(), Error> {
|
|||||||
)
|
)
|
||||||
.ey()?;
|
.ey()?;
|
||||||
|
|
||||||
let module = asset!("assets/rust_audio_worklet.js").to_string();
|
info!("loading mic worklet from {worklet_url:?}");
|
||||||
info!("loading mic worklet from {module:?}");
|
|
||||||
audio_context
|
audio_context
|
||||||
.audio_worklet()
|
.audio_worklet()
|
||||||
.ey()?
|
.ey()?
|
||||||
.add_module_with_options(&module, &options)
|
.add_module_with_options(worklet_url, &options)
|
||||||
.ey()?
|
.ey()?
|
||||||
.into_future()
|
.into_future()
|
||||||
.await
|
.await
|
||||||
@@ -212,7 +199,11 @@ impl super::AudioSystemInterface for WebAudioSystem {
|
|||||||
// Create MediaStreams to playback decoded audio
|
// Create MediaStreams to playback decoded audio
|
||||||
// The audio context is used to reproduce audio.
|
// The audio context is used to reproduce audio.
|
||||||
let webctx = configure_audio_context();
|
let webctx = configure_audio_context();
|
||||||
attach_worklet(&webctx).await?;
|
attach_worklet(
|
||||||
|
&webctx,
|
||||||
|
&asset!("/assets/rust_audio_worklet.js").to_string(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let processors = AudioProcessorSender::default();
|
let processors = AudioProcessorSender::default();
|
||||||
|
|
||||||
@@ -403,7 +394,7 @@ async fn run_encoder_worklet(
|
|||||||
audio_encoder.configure(&encoder_config);
|
audio_encoder.configure(&encoder_config);
|
||||||
info!("created audio encoder");
|
info!("created audio encoder");
|
||||||
|
|
||||||
let mut current_processor = AudioProcessor::new_plain();
|
let mut current_processor = AudioProcessor::new(false);
|
||||||
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
|
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
|
||||||
if let Some(new_processor) = processors.take() {
|
if let Some(new_processor) = processors.take() {
|
||||||
current_processor = new_processor;
|
current_processor = new_processor;
|
||||||
@@ -456,7 +447,8 @@ pub async fn network_connect(
|
|||||||
address: String,
|
address: String,
|
||||||
username: String,
|
username: String,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
event_rx: &mut UnboundedReceiver<Command>,
|
||||||
gui_config: &ClientConfig,
|
overrides: &ProxyOverrides,
|
||||||
|
state: SharedState,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
info!("connecting");
|
info!("connecting");
|
||||||
|
|
||||||
@@ -469,7 +461,7 @@ pub async fn network_connect(
|
|||||||
)
|
)
|
||||||
.ey()?;
|
.ey()?;
|
||||||
|
|
||||||
if let Some(server_hash) = &gui_config.cert_hash {
|
if let Some(server_hash) = &overrides.cert_hash {
|
||||||
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
|
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
|
||||||
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?;
|
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?;
|
||||||
}
|
}
|
||||||
@@ -515,7 +507,9 @@ pub async fn network_connect(
|
|||||||
let writer =
|
let writer =
|
||||||
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
|
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_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 absolute_url(path: &str) -> Result<Url, Error> {
|
pub fn absolute_url(path: &str) -> Result<Url, Error> {
|
||||||
@@ -523,3 +517,64 @@ pub fn absolute_url(path: &str) -> Result<Url, Error> {
|
|||||||
let location = window.location();
|
let location = window.location();
|
||||||
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
|
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,13 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
pub const VERSION: Option<&str> = option_env!("MUMBLE_WEB2_VERSION");
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
use app::Chat;
|
use crate::msghtml::process_message_html;
|
||||||
use app::Command;
|
use crate::AudioSettings;
|
||||||
use app::ConnectionState;
|
use crate::Chat;
|
||||||
use app::STATE;
|
use crate::Command;
|
||||||
|
use crate::ConnectionState;
|
||||||
use asynchronous_codec::FramedRead;
|
use asynchronous_codec::FramedRead;
|
||||||
use asynchronous_codec::FramedWrite;
|
use asynchronous_codec::FramedWrite;
|
||||||
use color_eyre::eyre::{bail, Error};
|
use color_eyre::eyre::{bail, Error};
|
||||||
use dioxus::prelude::*;
|
use dioxus_signals::ReadableExt as _;
|
||||||
|
use dioxus_signals::WritableExt as _;
|
||||||
use futures::select;
|
use futures::select;
|
||||||
use futures::AsyncRead;
|
use futures::AsyncRead;
|
||||||
use futures::AsyncWrite;
|
use futures::AsyncWrite;
|
||||||
use futures::FutureExt as _;
|
use futures::FutureExt as _;
|
||||||
use futures::SinkExt as _;
|
use futures::SinkExt as _;
|
||||||
use futures::StreamExt as _;
|
use futures::StreamExt as _;
|
||||||
use futures_channel::mpsc::UnboundedSender;
|
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||||
use msghtml::process_message_html;
|
|
||||||
use mumble_protocol::control::msgs;
|
use mumble_protocol::control::msgs;
|
||||||
use mumble_protocol::control::ControlCodec;
|
use mumble_protocol::control::ControlCodec;
|
||||||
use mumble_protocol::control::ControlPacket;
|
use mumble_protocol::control::ControlPacket;
|
||||||
@@ -27,21 +28,18 @@ use std::time::Duration;
|
|||||||
use tracing::error;
|
use tracing::error;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::app::SharedState;
|
||||||
|
use crate::app::State;
|
||||||
use crate::effects::AudioProcessor;
|
use crate::effects::AudioProcessor;
|
||||||
use crate::imp::{
|
use crate::imp::{
|
||||||
AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _, Platform,
|
spawn, AudioPlayer, AudioPlayerInterface as _, AudioSystem, AudioSystemInterface as _,
|
||||||
PlatformInterface as _,
|
Platform, PlatformInterface as _,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub mod app;
|
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>, state: SharedState) {
|
||||||
mod effects;
|
|
||||||
pub mod imp;
|
|
||||||
mod msghtml;
|
|
||||||
|
|
||||||
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
|
|
||||||
loop {
|
loop {
|
||||||
let Some(Command::Connect {
|
let Some(Command::Connect {
|
||||||
address,
|
target,
|
||||||
username,
|
username,
|
||||||
config,
|
config,
|
||||||
}) = event_rx.next().await
|
}) = event_rx.next().await
|
||||||
@@ -49,37 +47,43 @@ pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
|
|||||||
panic!("did not receive connect command")
|
panic!("did not receive connect command")
|
||||||
};
|
};
|
||||||
|
|
||||||
*STATE.server.write() = Default::default();
|
*state.server.write_unchecked() = Default::default();
|
||||||
*STATE.status.write() = ConnectionState::Connecting;
|
*state.status.write_unchecked() = ConnectionState::Connecting;
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
Platform::network_connect(address, username, &mut event_rx, &config).await
|
Platform::network_connect(target, username, &mut event_rx, &config, state.clone())
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
error!("could not connect {:?}", error);
|
error!("could not connect {:?}", error);
|
||||||
*STATE.status.write() = ConnectionState::Failed(error.to_string());
|
*state.status.write_unchecked() = ConnectionState::Failed(error.to_string());
|
||||||
} else {
|
} else {
|
||||||
*STATE.status.write() = ConnectionState::Disconnected;
|
*state.status.write_unchecked() = ConnectionState::Disconnected;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin + 'static>(
|
pub(crate) async fn sender_loop<W: AsyncWrite + Unpin + 'static>(
|
||||||
username: String,
|
mut outgoing: UnboundedReceiver<ControlPacket<Serverbound>>,
|
||||||
event_rx: &mut UnboundedReceiver<Command>,
|
|
||||||
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
|
|
||||||
mut writer: FramedWrite<W, ControlCodec<Serverbound, Clientbound>>,
|
mut writer: FramedWrite<W, ControlCodec<Serverbound, Clientbound>>,
|
||||||
) -> Result<(), Error> {
|
) {
|
||||||
let (mut send_chan, mut writer_recv_chan) = futures_channel::mpsc::unbounded();
|
while let Some(msg) = outgoing.next().await {
|
||||||
spawn(async move {
|
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
|
||||||
while let Some(msg) = writer_recv_chan.next().await {
|
info!("sending packet {:#?}", msg);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
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
|
// Get version packet
|
||||||
let version = match reader.next().await {
|
let version = match reader.next().await {
|
||||||
@@ -94,17 +98,17 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
|
|||||||
msg.set_version(0x000010204);
|
msg.set_version(0x000010204);
|
||||||
msg.set_release(format!("{} {}", "mumbleweb2", "6.9.0"));
|
msg.set_release(format!("{} {}", "mumbleweb2", "6.9.0"));
|
||||||
//msg.set_os("Chrome".to_string());
|
//msg.set_os("Chrome".to_string());
|
||||||
send_chan.send(msg.into()).await.unwrap();
|
outgoing.send(msg.into()).await.unwrap();
|
||||||
|
|
||||||
// Send authenticate packet
|
// Send authenticate packet
|
||||||
let mut msg = msgs::Authenticate::new();
|
let mut msg = msgs::Authenticate::new();
|
||||||
msg.set_username(username);
|
msg.set_username(username);
|
||||||
msg.set_opus(true);
|
msg.set_opus(true);
|
||||||
send_chan.send(msg.into()).await.unwrap();
|
outgoing.send(msg.into()).await.unwrap();
|
||||||
|
|
||||||
// Spawn worker to send pings
|
// Spawn worker to send pings
|
||||||
{
|
{
|
||||||
let mut send_chan = send_chan.clone();
|
let mut send_chan = outgoing.clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
if let Err(_) = send_chan.send(msgs::Ping::new().into()).await {
|
if let Err(_) = send_chan.send(msgs::Ping::new().into()).await {
|
||||||
@@ -117,10 +121,11 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut audio = AudioSystem::new().await?;
|
let mut audio = AudioSystem::new().await?;
|
||||||
|
audio.set_processor(AudioProcessor::new(audio_settings.denoise));
|
||||||
{
|
{
|
||||||
let send_chan = send_chan.clone();
|
let send_chan = outgoing.clone();
|
||||||
let mut sequence_num = 0;
|
let mut sequence_num = 0;
|
||||||
audio.start_recording(move |opus_frame, is_terminator| {
|
if let Err(err) = audio.start_recording(move |opus_frame, is_terminator| {
|
||||||
let _ =
|
let _ =
|
||||||
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
|
send_chan.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
|
||||||
_dst: std::marker::PhantomData,
|
_dst: std::marker::PhantomData,
|
||||||
@@ -131,7 +136,9 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
|
|||||||
position_info: None,
|
position_info: None,
|
||||||
})));
|
})));
|
||||||
sequence_num = sequence_num.wrapping_add(2);
|
sequence_num = sequence_num.wrapping_add(2);
|
||||||
});
|
}) {
|
||||||
|
error!("could not begin recording: {err:?}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create map of session_id -> AudioDecoder
|
// Create map of session_id -> AudioDecoder
|
||||||
@@ -149,7 +156,7 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
|
|||||||
if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) {
|
if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) {
|
||||||
info!("receiving packet {:#?}", msg);
|
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 {
|
if let Err(err) = res {
|
||||||
error!("error accepting packet {:?}", err)
|
error!("error accepting packet {:?}", err)
|
||||||
}
|
}
|
||||||
@@ -168,7 +175,7 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
|
|||||||
match command {
|
match command {
|
||||||
Some(Command::Disconnect) => break,
|
Some(Command::Disconnect) => break,
|
||||||
Some(command) => {
|
Some(command) => {
|
||||||
let res = accept_command(command, &mut send_chan, &mut audio);
|
let res = accept_command(command, &mut outgoing, &mut audio, &state);
|
||||||
if let Err(err) = res {
|
if let Err(err) = res {
|
||||||
info!("error accepting command {:?}", err)
|
info!("error accepting command {:?}", err)
|
||||||
}
|
}
|
||||||
@@ -178,7 +185,7 @@ pub async fn network_loop<R: AsyncRead + Unpin + 'static, W: AsyncWrite + Unpin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = send_chan.close();
|
let _ = outgoing.close();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -187,9 +194,10 @@ fn accept_command(
|
|||||||
command: Command,
|
command: Command,
|
||||||
send_chan: &mut UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
|
send_chan: &mut UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
|
||||||
audio: &mut AudioSystem,
|
audio: &mut AudioSystem,
|
||||||
|
state: &State,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
use Command::*;
|
use Command::*;
|
||||||
let Some(session) = STATE.server.read().session else {
|
let Some(session) = state.server.read().session else {
|
||||||
bail!("no session id")
|
bail!("no session id")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,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 {
|
let Some(me) = server.session else {
|
||||||
bail!("not signed in with a session id")
|
bail!("not signed in with a session id")
|
||||||
};
|
};
|
||||||
@@ -253,7 +261,7 @@ fn accept_command(
|
|||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut server = STATE.server.write();
|
let mut server = state.server.write_unchecked();
|
||||||
let Some(me) = server.session else {
|
let Some(me) = server.session else {
|
||||||
bail!("not signed in with a session id")
|
bail!("not signed in with a session id")
|
||||||
};
|
};
|
||||||
@@ -288,12 +296,8 @@ fn accept_command(
|
|||||||
let _ = send_chan.unbounded_send(u.into());
|
let _ = send_chan.unbounded_send(u.into());
|
||||||
}
|
}
|
||||||
Connect { .. } | Disconnect => (),
|
Connect { .. } | Disconnect => (),
|
||||||
UpdateMicEffects { denoise } => {
|
UpdateAudioSettings(AudioSettings { denoise }) => {
|
||||||
if denoise {
|
audio.set_processor(AudioProcessor::new(denoise));
|
||||||
audio.set_processor(AudioProcessor::new_denoising());
|
|
||||||
} else {
|
|
||||||
audio.set_processor(AudioProcessor::new_plain());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +308,7 @@ fn accept_packet(
|
|||||||
msg: ControlPacket<mumble_protocol::Clientbound>,
|
msg: ControlPacket<mumble_protocol::Clientbound>,
|
||||||
audio_context: &mut AudioSystem,
|
audio_context: &mut AudioSystem,
|
||||||
player_map: &mut HashMap<u32, AudioPlayer>,
|
player_map: &mut HashMap<u32, AudioPlayer>,
|
||||||
|
state: &State,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
match msg {
|
match msg {
|
||||||
ControlPacket::UDPTunnel(u) => {
|
ControlPacket::UDPTunnel(u) => {
|
||||||
@@ -340,15 +345,15 @@ fn accept_packet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ControlPacket::ChannelState(u) => {
|
ControlPacket::ChannelState(u) => {
|
||||||
let mut server = STATE.server.write();
|
let mut server = state.server.write_unchecked();
|
||||||
server.channels_state.update_from_channel_state(&u);
|
server.channels_state.update_from_channel_state(&u);
|
||||||
}
|
}
|
||||||
ControlPacket::ChannelRemove(u) => {
|
ControlPacket::ChannelRemove(u) => {
|
||||||
let mut server = STATE.server.write();
|
let mut server = state.server.write_unchecked();
|
||||||
server.channels_state.update_from_channel_remove(&u);
|
server.channels_state.update_from_channel_remove(&u);
|
||||||
}
|
}
|
||||||
ControlPacket::UserState(u) => {
|
ControlPacket::UserState(u) => {
|
||||||
let mut server = STATE.server.write();
|
let mut server = state.server.write_unchecked();
|
||||||
let server = &mut *server;
|
let server = &mut *server;
|
||||||
let id = u.get_session();
|
let id = u.get_session();
|
||||||
|
|
||||||
@@ -392,7 +397,7 @@ fn accept_packet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ControlPacket::UserRemove(u) => {
|
ControlPacket::UserRemove(u) => {
|
||||||
let mut server = STATE.server.write();
|
let mut server = state.server.write_unchecked();
|
||||||
let id = u.get_session();
|
let id = u.get_session();
|
||||||
if let Some(state) = server.users.remove(&id) {
|
if let Some(state) = server.users.remove(&id) {
|
||||||
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
if let Some(parent) = server.channels_state.channels.get_mut(&state.channel) {
|
||||||
@@ -401,7 +406,7 @@ fn accept_packet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ControlPacket::TextMessage(u) => {
|
ControlPacket::TextMessage(u) => {
|
||||||
let mut server = STATE.server.write();
|
let mut server = state.server.write_unchecked();
|
||||||
if u.has_message() {
|
if u.has_message() {
|
||||||
let text = u.get_message().to_string();
|
let text = u.get_message().to_string();
|
||||||
server.chat.push(Chat {
|
server.chat.push(Chat {
|
||||||
@@ -416,8 +421,8 @@ fn accept_packet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ControlPacket::ServerSync(u) => {
|
ControlPacket::ServerSync(u) => {
|
||||||
*STATE.status.write() = ConnectionState::Connected;
|
*state.status.write_unchecked() = ConnectionState::Connected;
|
||||||
let mut server = STATE.server.write();
|
let mut server = state.server.write_unchecked();
|
||||||
if u.has_welcome_text() {
|
if u.has_welcome_text() {
|
||||||
let text = u.get_welcome_text().to_string();
|
let text = u.get_welcome_text().to_string();
|
||||||
server.chat.push(Chat {
|
server.chat.push(Chat {
|
||||||
@@ -3,5 +3,10 @@ name = "mumble-web2-common"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
networking = ["dep:tokio", "dep:color-eyre"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
tokio = { version = "1", features = ["net", "time"], optional = true }
|
||||||
|
color-eyre = { version = "0.6", optional = true }
|
||||||
|
|||||||
+69
-1
@@ -1,7 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
pub struct ClientConfig {
|
pub struct ProxyOverrides {
|
||||||
pub proxy_url: Option<String>,
|
pub proxy_url: Option<String>,
|
||||||
pub cert_hash: Option<Vec<u8>>,
|
pub cert_hash: Option<Vec<u8>>,
|
||||||
pub any_server: bool,
|
pub any_server: bool,
|
||||||
@@ -16,3 +16,71 @@ pub struct ServerStatus {
|
|||||||
pub max_users: Option<u32>,
|
pub max_users: Option<u32>,
|
||||||
pub bandwidth: 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::time::Duration;
|
||||||
|
use tokio::net::{lookup_host, UdpSocket};
|
||||||
|
|
||||||
|
let dest = lookup_host(format!("{}:{}", address, port))
|
||||||
|
.await?
|
||||||
|
.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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+7
-9
@@ -1,14 +1,12 @@
|
|||||||
localhost:64444 {
|
localhost:64444 {
|
||||||
tls internal
|
tls internal
|
||||||
|
|
||||||
# Proxy /config path to mumble-web2-proxy
|
# Proxy /config path to mumble-web2-proxy
|
||||||
reverse_proxy /config http://127.0.0.1:4400
|
reverse_proxy /overrides http://127.0.0.1:4400
|
||||||
|
|
||||||
# Proxy /status path to mumble-web2-proxy
|
# Proxy /status path to mumble-web2-proxy
|
||||||
reverse_proxy /status http://127.0.0.1:4400
|
reverse_proxy /status http://127.0.0.1:4400
|
||||||
|
|
||||||
|
|
||||||
# Proxy root path to dx-serve
|
|
||||||
reverse_proxy http://127.0.0.1:8080
|
|
||||||
|
|
||||||
|
# Proxy root path to dx-serve
|
||||||
|
reverse_proxy http://127.0.0.1:8080
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+21
@@ -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"
|
||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
# volumes:
|
# volumes:
|
||||||
# - ..:/app
|
# - ..:/app
|
||||||
# environment:
|
# environment:
|
||||||
# - MUMBLE_WEB2_GUI_CONFIG_URL=https://localhost:64444/config
|
# - MUMBLE_WEB2_PROXY_OVERRIDES_URL=https://localhost:64444/overrides
|
||||||
# stdin_open: true
|
# stdin_open: true
|
||||||
# tty: true
|
# tty: true
|
||||||
# command: >
|
# command: >
|
||||||
|
|||||||
+6
-129
@@ -4,103 +4,11 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Web Dependencies
|
|
||||||
# ================
|
|
||||||
dioxus-web = { version = "0.7.1", optional = true }
|
|
||||||
wasm-bindgen = { version = "^0.2.92", optional = true }
|
|
||||||
wasm-bindgen-futures = { version = "^0.4.42", optional = true }
|
|
||||||
wasm-streams = { version = "^0.4.0", optional = true }
|
|
||||||
serde-wasm-bindgen = { version = "^0.6.5", optional = true }
|
|
||||||
js-sys = { version = "^0.3.70", optional = true }
|
|
||||||
web-sys = { version = "^0.3.72", features = [
|
|
||||||
"WebTransport",
|
|
||||||
"console",
|
|
||||||
"WebTransportOptions",
|
|
||||||
"WebTransportBidirectionalStream",
|
|
||||||
"WebTransportSendStream",
|
|
||||||
"WebTransportReceiveStream",
|
|
||||||
"Navigator",
|
|
||||||
"MediaDevices",
|
|
||||||
"AudioDecoder",
|
|
||||||
"AudioDecoderInit",
|
|
||||||
"AudioData",
|
|
||||||
"AudioEncoderConfig",
|
|
||||||
"AudioDecoderConfig",
|
|
||||||
"EncodedAudioChunk",
|
|
||||||
"EncodedAudioChunkInit",
|
|
||||||
"EncodedAudioChunkType",
|
|
||||||
"CodecState",
|
|
||||||
"AudioContext",
|
|
||||||
"AudioContextOptions",
|
|
||||||
"MediaStream",
|
|
||||||
"GainNode",
|
|
||||||
"MediaStreamAudioSourceNode",
|
|
||||||
"BaseAudioContext",
|
|
||||||
"AudioDestinationNode",
|
|
||||||
"AudioWorkletNode",
|
|
||||||
"AudioWorklet",
|
|
||||||
"AudioWorkletProcessor",
|
|
||||||
"MessagePort",
|
|
||||||
"MediaStreamConstraints",
|
|
||||||
"WorkletOptions",
|
|
||||||
"AudioEncoder",
|
|
||||||
"AudioEncoderInit",
|
|
||||||
"AudioDataInit",
|
|
||||||
"HtmlAnchorElement",
|
|
||||||
"Url",
|
|
||||||
"Blob",
|
|
||||||
"AudioDataCopyToOptions",
|
|
||||||
"AudioSampleFormat",
|
|
||||||
"Storage",
|
|
||||||
], optional = true }
|
|
||||||
gloo-timers = { version = "^0.3.0", features = ["futures"], optional = true }
|
|
||||||
tracing-web = { version = "^0.1.3", optional = true }
|
|
||||||
|
|
||||||
# Desktop Dependecies
|
|
||||||
# ===================
|
|
||||||
tokio = { version = "^1.41.1", features = ["net", "rt"], optional = true }
|
|
||||||
tokio-rustls = { version = "^0.26.0", optional = true }
|
|
||||||
opus = { version = "0.3.0", optional = true }
|
|
||||||
cpal = { version = "0.15.3", optional = true }
|
|
||||||
dasp_ring_buffer = { version = "0.11.0", optional = true }
|
|
||||||
etcetera = { version = "0.10.0", optional = true }
|
|
||||||
|
|
||||||
# Base Dependencies
|
|
||||||
# ================
|
|
||||||
dioxus = { version = "0.7.2" }
|
dioxus = { version = "0.7.2" }
|
||||||
once_cell = "1.19.0"
|
dioxus-web = { version = "0.7.2", optional = true }
|
||||||
asynchronous-codec = { workspace = true }
|
mumble-web2-client = { version = "0.1.0", path = "../client" }
|
||||||
futures = "^0.3.30"
|
mumble-web2-common = { version = "0.1.0", path = "../common" }
|
||||||
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"
|
color-eyre = "^0.6.3"
|
||||||
crossbeam-queue = "^0.3.11"
|
|
||||||
lol_html = "^2.2.0"
|
|
||||||
base64 = "^0.22"
|
|
||||||
mime_guess = "^2.0.5"
|
|
||||||
async_cell = "^0.2.3"
|
|
||||||
reqwest = { version = "^0.12.22", features = ["json"] }
|
|
||||||
dioxus-asset-resolver = "0.7.2"
|
|
||||||
|
|
||||||
|
|
||||||
# Denoising
|
|
||||||
# =========
|
|
||||||
deep_filter = { git = "https://github.com/Rikorose/DeepFilterNet.git", rev = "d375b2d8309e0935d165700c91da9de862a99c31", features = [
|
|
||||||
"tract",
|
|
||||||
] }
|
|
||||||
crossbeam = "0.8.4"
|
|
||||||
|
|
||||||
# Platform Integration
|
# Platform Integration
|
||||||
# ====================
|
# ====================
|
||||||
@@ -108,50 +16,19 @@ crossbeam = "0.8.4"
|
|||||||
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
|
[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 }
|
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true }
|
||||||
|
|
||||||
# Android dependencies for requesting permissions
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
|
||||||
android-permissions = "0.1.2"
|
|
||||||
jni = "0.21.1"
|
|
||||||
ndk-context = "0.1.1"
|
|
||||||
|
|
||||||
[patch.crates-io]
|
|
||||||
tract-hir = "=0.12.4"
|
|
||||||
tract-core = "=0.12.4"
|
|
||||||
tract-onnx = "=0.12.4"
|
|
||||||
tract-pulse = "=0.12.4"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
web = [
|
web = [
|
||||||
"dioxus/web",
|
"dioxus/web",
|
||||||
"dioxus-web",
|
"dioxus-web",
|
||||||
"wasm-bindgen",
|
"mumble-web2-client/web",
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"wasm-streams",
|
|
||||||
"serde-wasm-bindgen",
|
|
||||||
"js-sys",
|
|
||||||
"web-sys",
|
|
||||||
"gloo-timers",
|
|
||||||
"tracing-web",
|
|
||||||
"deep_filter/wasm",
|
|
||||||
"rfd",
|
"rfd",
|
||||||
]
|
]
|
||||||
desktop = [
|
desktop = [
|
||||||
"dioxus/desktop",
|
"dioxus/desktop",
|
||||||
"tokio",
|
"mumble-web2-client/desktop",
|
||||||
"tokio-rustls",
|
|
||||||
"tracing-subscriber/env-filter",
|
|
||||||
"opus",
|
|
||||||
"cpal",
|
|
||||||
"dasp_ring_buffer",
|
|
||||||
"rfd/xdg-portal",
|
"rfd/xdg-portal",
|
||||||
"etcetera",
|
|
||||||
]
|
]
|
||||||
mobile = [
|
mobile = [
|
||||||
"dioxus/mobile",
|
"dioxus/mobile",
|
||||||
"tokio",
|
"mumble-web2-client/mobile"
|
||||||
"tokio-rustls",
|
|
||||||
"tracing-subscriber/env-filter",
|
|
||||||
"opus",
|
|
||||||
"cpal",
|
|
||||||
"dasp_ring_buffer",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 |
@@ -16,6 +16,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#main {
|
#main {
|
||||||
|
visibility: visible;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -431,3 +432,365 @@ a:visited {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
-881
@@ -1,881 +0,0 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
|
||||||
use mime_guess::Mime;
|
|
||||||
use mumble_web2_common::{ClientConfig, ServerStatus};
|
|
||||||
use ordermap::OrderSet;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
|
|
||||||
use crate::imp::{Platform, PlatformInterface as _};
|
|
||||||
|
|
||||||
pub type ChannelId = u32;
|
|
||||||
pub type UserId = u32;
|
|
||||||
|
|
||||||
pub enum ConnectionState {
|
|
||||||
Disconnected,
|
|
||||||
Connecting,
|
|
||||||
Connected,
|
|
||||||
Failed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum Command {
|
|
||||||
Connect {
|
|
||||||
address: String,
|
|
||||||
username: String,
|
|
||||||
config: ClientConfig,
|
|
||||||
},
|
|
||||||
SendChat {
|
|
||||||
markdown: String,
|
|
||||||
channels: Vec<ChannelId>,
|
|
||||||
},
|
|
||||||
SendFile {
|
|
||||||
bytes: Vec<u8>,
|
|
||||||
name: String,
|
|
||||||
mime: Option<Mime>,
|
|
||||||
channels: Vec<ChannelId>,
|
|
||||||
},
|
|
||||||
SetMute {
|
|
||||||
mute: bool,
|
|
||||||
},
|
|
||||||
SetDeaf {
|
|
||||||
deaf: bool,
|
|
||||||
},
|
|
||||||
EnterChannel {
|
|
||||||
channel: ChannelId,
|
|
||||||
user: UserId,
|
|
||||||
},
|
|
||||||
UpdateMicEffects {
|
|
||||||
denoise: bool,
|
|
||||||
},
|
|
||||||
Disconnect,
|
|
||||||
}
|
|
||||||
|
|
||||||
use Command::*;
|
|
||||||
use ConnectionState::*;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct UserState {
|
|
||||||
pub name: String,
|
|
||||||
pub channel: ChannelId,
|
|
||||||
pub deaf: bool,
|
|
||||||
pub mute: bool,
|
|
||||||
pub suppress: bool,
|
|
||||||
pub self_deaf: bool,
|
|
||||||
pub self_mute: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserState {
|
|
||||||
pub fn icon(&self) -> UserIcon {
|
|
||||||
if self.deaf || self.self_deaf {
|
|
||||||
UserIcon::Deafened
|
|
||||||
} else if self.mute || self.self_mute {
|
|
||||||
UserIcon::Muted
|
|
||||||
} else if self.suppress {
|
|
||||||
UserIcon::Suppressed
|
|
||||||
} else {
|
|
||||||
UserIcon::Normal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Chat {
|
|
||||||
pub raw: String,
|
|
||||||
pub dangerous_html: String,
|
|
||||||
pub sender: Option<UserId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ChannelState {
|
|
||||||
pub name: String,
|
|
||||||
pub children: OrderSet<ChannelId>,
|
|
||||||
pub users: OrderSet<UserId>,
|
|
||||||
pub parent: Option<ChannelId>,
|
|
||||||
pub position: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelState {
|
|
||||||
pub fn update_from_channel_state(
|
|
||||||
&mut self,
|
|
||||||
channel_state: &mumble_protocol::control::msgs::ChannelState,
|
|
||||||
) {
|
|
||||||
if channel_state.has_position() {
|
|
||||||
self.position = channel_state.get_position();
|
|
||||||
}
|
|
||||||
if channel_state.has_parent() {
|
|
||||||
self.parent = Some(channel_state.get_parent());
|
|
||||||
}
|
|
||||||
if channel_state.has_name() {
|
|
||||||
self.name = channel_state.get_name().to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ChannelsState {
|
|
||||||
pub channels: HashMap<ChannelId, ChannelState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelsState {
|
|
||||||
pub fn update_from_channel_state(
|
|
||||||
&mut self,
|
|
||||||
channel_state: &mumble_protocol::control::msgs::ChannelState,
|
|
||||||
) {
|
|
||||||
self.channels
|
|
||||||
.entry(channel_state.get_channel_id())
|
|
||||||
.or_default()
|
|
||||||
.update_from_channel_state(channel_state);
|
|
||||||
|
|
||||||
self.update_channel_parents();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_from_channel_remove(
|
|
||||||
&mut self,
|
|
||||||
channel_remove: &mumble_protocol::control::msgs::ChannelRemove,
|
|
||||||
) {
|
|
||||||
self.channels.remove(&channel_remove.get_channel_id());
|
|
||||||
self.update_channel_parents();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_channel_parents(&mut self) {
|
|
||||||
// Zero out existing children
|
|
||||||
for state in self.channels.values_mut() {
|
|
||||||
state.children.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut to_sort: Vec<(ChannelId, Option<ChannelId>, i32, String)> = Vec::new();
|
|
||||||
for (id, state) in self.channels.iter() {
|
|
||||||
// Handle channels with no parent (the root channel)
|
|
||||||
let Some(parent_id) = state.parent else {
|
|
||||||
to_sort.push((*id, None, 0, state.name.clone()));
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// If a channel has a parent that we haven't gotten a channel
|
|
||||||
// state packet for, ignore it
|
|
||||||
if !self.channels.contains_key(&parent_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
to_sort.push((*id, Some(parent_id), state.position, state.name.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pos_name: HashMap<ChannelId, (i32, String)> = self
|
|
||||||
.channels
|
|
||||||
.iter()
|
|
||||||
.map(|(&id, state)| (id, (state.position, state.name.clone())))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut updated: HashSet<ChannelId> = HashSet::new();
|
|
||||||
|
|
||||||
while updated.len() < to_sort.len() {
|
|
||||||
for &(id, ref parent_id, position, ref name) in &to_sort {
|
|
||||||
let Some(parent_id) = parent_id else {
|
|
||||||
updated.insert(id);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
if updated.contains(&id) || !updated.contains(&parent_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap should never fail here since we pre filter
|
|
||||||
let parent = self.channels.get_mut(&parent_id).unwrap();
|
|
||||||
|
|
||||||
let mut insert_index = parent.children.len();
|
|
||||||
for (i, &child) in parent.children.iter().enumerate() {
|
|
||||||
let (p, ref n) = pos_name[&child];
|
|
||||||
if (position == p && name < n) || p > position {
|
|
||||||
insert_index = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parent.children.insert_before(insert_index, id);
|
|
||||||
updated.insert(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ServerState {
|
|
||||||
pub channels_state: ChannelsState,
|
|
||||||
pub users: HashMap<UserId, UserState>,
|
|
||||||
pub chat: Vec<Chat>,
|
|
||||||
pub session: Option<UserId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ServerState {
|
|
||||||
pub fn this_user(&self) -> Option<&UserState> {
|
|
||||||
self.users.get(&self.session?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct State {
|
|
||||||
pub status: GlobalSignal<ConnectionState>,
|
|
||||||
pub server: GlobalSignal<ServerState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub static STATE: State = State {
|
|
||||||
status: Signal::global(|| Disconnected),
|
|
||||||
server: Signal::global(|| Default::default()),
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum UserIcon {
|
|
||||||
Normal,
|
|
||||||
Muted,
|
|
||||||
Deafened,
|
|
||||||
Suppressed,
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserIcon {
|
|
||||||
pub fn url(self) -> Option<Asset> {
|
|
||||||
// speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/
|
|
||||||
// mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/
|
|
||||||
|
|
||||||
use UserIcon::*;
|
|
||||||
Some(match self {
|
|
||||||
Normal => asset!("assets/mic-svgrepo-com.svg"),
|
|
||||||
Muted | Suppressed => asset!("assets/mic-off-svgrepo-com.svg"),
|
|
||||||
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
|
|
||||||
None => return Option::None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn UserPill(name: String, icon: UserIcon, isself: bool) -> Element {
|
|
||||||
let color = match icon {
|
|
||||||
UserIcon::Normal => "var(--accent-normal)",
|
|
||||||
UserIcon::Muted => "var(--accent-muted)",
|
|
||||||
UserIcon::Suppressed | UserIcon::Deafened => "var(--accent-deafened)",
|
|
||||||
UserIcon::None => "var(--accent-normal)",
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
div {
|
|
||||||
class: match isself { true => "userpil is_self", false => "userpil" },
|
|
||||||
style: "background-color: {color}",
|
|
||||||
{ icon.url().map(|url| rsx!(img { src: url })) }
|
|
||||||
"\u{00A0}{name}\u{00A0}"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn User(id: UserId) -> Element {
|
|
||||||
let server = STATE.server.read();
|
|
||||||
match server.users.get(&id) {
|
|
||||||
Some(state) => rsx!(UserPill {
|
|
||||||
name: state.name.clone(),
|
|
||||||
icon: state.icon(),
|
|
||||||
isself: server.session.unwrap() == id,
|
|
||||||
}),
|
|
||||||
None => rsx!(UserPill {
|
|
||||||
name: format!("unknown user ({id})"),
|
|
||||||
icon: UserIcon::None,
|
|
||||||
isself: false,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn Channel(id: ChannelId) -> Element {
|
|
||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
|
||||||
let server = STATE.server.read();
|
|
||||||
let user = server.session.unwrap();
|
|
||||||
let Some(state) = server.channels_state.channels.get(&id) else {
|
|
||||||
return rsx!("missing channel {id}");
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut open = use_signal(|| true);
|
|
||||||
|
|
||||||
let has_children = !state.users.is_empty() || !state.children.is_empty();
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
div {
|
|
||||||
class: "channel_details",
|
|
||||||
|
|
||||||
div {
|
|
||||||
class: "channel_header",
|
|
||||||
// Arrow: only toggles open
|
|
||||||
if has_children {
|
|
||||||
span {
|
|
||||||
class: "channel_arrow",
|
|
||||||
onclick: move |evt| {
|
|
||||||
evt.stop_propagation();
|
|
||||||
evt.prevent_default();
|
|
||||||
let mut w = open.write();
|
|
||||||
*w = !*w;
|
|
||||||
},
|
|
||||||
if *open.read() { "▾" } else { "▸" }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
span {
|
|
||||||
class: "channel_arrow channel_arrow--placeholder",
|
|
||||||
" "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clickable row area (everything except the arrow)
|
|
||||||
div {
|
|
||||||
class: "channel_row_click",
|
|
||||||
ondblclick: move |evt| {
|
|
||||||
evt.stop_propagation();
|
|
||||||
evt.prevent_default();
|
|
||||||
net.send(EnterChannel { channel: id, user })
|
|
||||||
},
|
|
||||||
// remove dblclick from the inner span
|
|
||||||
span {
|
|
||||||
class: "channel_title",
|
|
||||||
"{state.name}"
|
|
||||||
}
|
|
||||||
// if you add icons/badges later, put them here too
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if *open.read() && has_children {
|
|
||||||
div {
|
|
||||||
class: "channel_children",
|
|
||||||
for id in state.users.iter() {
|
|
||||||
User { id: *id }
|
|
||||||
}
|
|
||||||
for child in state.children.iter() {
|
|
||||||
Channel { id: *child }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(feature = "desktop", feature = "web"))]
|
|
||||||
pub fn pick_and_send_file(net: &Coroutine<Command>) {
|
|
||||||
let channels = if let Some(user) = STATE.server.read().this_user() {
|
|
||||||
vec![user.channel]
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let dialog = rfd::AsyncFileDialog::new().pick_file();
|
|
||||||
let sender = net.tx();
|
|
||||||
spawn(async move {
|
|
||||||
let Some(handle) = dialog.await else { return };
|
|
||||||
let name = handle.file_name();
|
|
||||||
let bytes = handle.read().await;
|
|
||||||
let mime = mime_guess::from_path(&name).first();
|
|
||||||
let _ = sender.unbounded_send(SendFile {
|
|
||||||
bytes,
|
|
||||||
name,
|
|
||||||
mime,
|
|
||||||
channels,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
#[cfg(not(any(feature = "desktop", feature = "web")))]
|
|
||||||
pub fn pick_and_send_file(net: &Coroutine<Command>) {}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ChatView() -> Element {
|
|
||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
|
||||||
let server = STATE.server.read();
|
|
||||||
let mut draft = use_signal(|| "".to_string());
|
|
||||||
|
|
||||||
let mut do_send = move || {
|
|
||||||
if let Some(user) = STATE.server.read().this_user() {
|
|
||||||
net.send(SendChat {
|
|
||||||
markdown: draft.write().split_off(0),
|
|
||||||
channels: vec![user.channel],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
div {
|
|
||||||
class: "chat_panel",
|
|
||||||
div {
|
|
||||||
class: "chat_history",
|
|
||||||
for chat in server.chat.iter() {
|
|
||||||
div {
|
|
||||||
class: "chat_message",
|
|
||||||
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
|
|
||||||
UserPill {
|
|
||||||
name: sender.name.clone(),
|
|
||||||
icon: UserIcon::None,
|
|
||||||
isself: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
dangerous_inner_html: "{chat.dangerous_html}",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "chat_box_wrapper",
|
|
||||||
div {
|
|
||||||
class: "chat_box",
|
|
||||||
input {
|
|
||||||
placeholder: "say something",
|
|
||||||
value: "{draft.read()}",
|
|
||||||
oninput: move |evt| draft.set(evt.value().clone()),
|
|
||||||
onkeypress: move |evt: Event<KeyboardData>| {
|
|
||||||
if evt.code() == Code::Enter && evt.modifiers().is_empty() {
|
|
||||||
do_send();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
span {
|
|
||||||
onclick: move |_| pick_and_send_file(&net),
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
|
|
||||||
"attach_file",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
span {
|
|
||||||
onclick: move |_| do_send(),
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
|
|
||||||
"send",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//button {
|
|
||||||
// onclick: move |_| do_send(),
|
|
||||||
// "Send"
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ControlView(config: Resource<ClientConfig>) -> Element {
|
|
||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
|
||||||
let status = &STATE.status;
|
|
||||||
let server = STATE.server.read();
|
|
||||||
let Some(&UserState {
|
|
||||||
deaf,
|
|
||||||
self_deaf,
|
|
||||||
mute,
|
|
||||||
suppress,
|
|
||||||
self_mute,
|
|
||||||
ref name,
|
|
||||||
channel,
|
|
||||||
..
|
|
||||||
}) = server.this_user()
|
|
||||||
else {
|
|
||||||
return rsx!();
|
|
||||||
};
|
|
||||||
|
|
||||||
let current_channel_name = server.channels_state.channels[&channel].name.clone();
|
|
||||||
|
|
||||||
let proxy_url = config
|
|
||||||
.read_unchecked()
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|gui_config| gui_config.proxy_url.clone());
|
|
||||||
|
|
||||||
let connecting_color = "yellow";
|
|
||||||
let connected_color = "oklch(0.55 0.1184 141.35)";
|
|
||||||
let disconnected_color = "gray";
|
|
||||||
let failed_color = "red";
|
|
||||||
|
|
||||||
let connection_status = match &*status.read() {
|
|
||||||
Connecting => rsx! {
|
|
||||||
div {
|
|
||||||
class: "connection_status",
|
|
||||||
style: "color: {connecting_color};",
|
|
||||||
div {
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
"signal_cellular_alt_2_bar"
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
class: "status_text",
|
|
||||||
" Connecting"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Connected => rsx! {
|
|
||||||
div {
|
|
||||||
class: "connection_status",
|
|
||||||
div {
|
|
||||||
style: "color: {connected_color};",
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
"signal_cellular_alt"
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
class: "status_text",
|
|
||||||
" Connected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "channel_text",
|
|
||||||
span { "{current_channel_name}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Disconnected => rsx! {
|
|
||||||
div {
|
|
||||||
class: "connection_status",
|
|
||||||
style: "color: {disconnected_color};",
|
|
||||||
div {
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
"signal_disconnected"
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
class: "status_text",
|
|
||||||
" Disconnected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Failed(_) => rsx! {
|
|
||||||
div {
|
|
||||||
class: "connection_status",
|
|
||||||
style: "color: {failed_color};",
|
|
||||||
div {
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
"error"
|
|
||||||
}
|
|
||||||
span {
|
|
||||||
class: "status_text",
|
|
||||||
" Failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let denoise = use_signal(|| false);
|
|
||||||
rsx!(
|
|
||||||
// Server control
|
|
||||||
div {
|
|
||||||
class: "button_row",
|
|
||||||
div {
|
|
||||||
{connection_status}
|
|
||||||
}
|
|
||||||
span { class: "spacer" }
|
|
||||||
button {
|
|
||||||
class: "toggle_button",
|
|
||||||
onclick: move |_| net.send(Disconnect),
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
"signal_disconnected"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hr { style: "width: 100%;" }
|
|
||||||
// User control
|
|
||||||
div {
|
|
||||||
class: "button_row",
|
|
||||||
button {
|
|
||||||
class: "user_edit_button",
|
|
||||||
span {
|
|
||||||
class: "material-symbols-outlined",
|
|
||||||
style: "color: oklch(0.65 0.2245 28.06);",
|
|
||||||
"person_edit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "user_info",
|
|
||||||
div {
|
|
||||||
span { class: "user_name", "{name}" }
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
span { class: "user_data", "some data" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
span { class: "spacer" }
|
|
||||||
button {
|
|
||||||
class: match denoise() {
|
|
||||||
true => "toggle_button is_on",
|
|
||||||
false => "toggle_button",
|
|
||||||
},
|
|
||||||
role: "switch",
|
|
||||||
aria_checked: denoise(),
|
|
||||||
onclick: move |_| {
|
|
||||||
let new_denoise = !denoise();
|
|
||||||
*denoise.write_unchecked() = new_denoise;
|
|
||||||
net.send(UpdateMicEffects { denoise: new_denoise })
|
|
||||||
},
|
|
||||||
match denoise() {
|
|
||||||
true => rsx!(span { class: "material-symbols-outlined", "cadence"}),
|
|
||||||
false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: match mute || suppress || self_mute {
|
|
||||||
true => "toggle_button is_on",
|
|
||||||
false => "toggle_button",
|
|
||||||
},
|
|
||||||
role: "switch",
|
|
||||||
aria_checked: mute || suppress || self_mute,
|
|
||||||
disabled: mute || suppress,
|
|
||||||
onclick: move |_| net.send(SetMute { mute: !self_mute }),
|
|
||||||
match mute || suppress || self_mute {
|
|
||||||
true => rsx!(span { class: "material-symbols-outlined", "mic_off"}),
|
|
||||||
false => rsx!(span { class: "material-symbols-outlined", "mic"}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
class: match deaf || self_deaf {
|
|
||||||
true => "toggle_button in_on",
|
|
||||||
false => "toggle_button",
|
|
||||||
},
|
|
||||||
role: "switch",
|
|
||||||
aria_checked: deaf || self_deaf,
|
|
||||||
disabled: deaf,
|
|
||||||
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
|
|
||||||
match deaf || self_deaf {
|
|
||||||
true => rsx!(span { class: "material-symbols-outlined", "volume_off"}),
|
|
||||||
false => rsx!(span { class: "material-symbols-outlined", "volume_up"}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn ServerView(config: Resource<ClientConfig>) -> Element {
|
|
||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
|
||||||
let server = STATE.server.read();
|
|
||||||
let Some(&UserState {
|
|
||||||
deaf,
|
|
||||||
self_deaf,
|
|
||||||
mute,
|
|
||||||
self_mute,
|
|
||||||
..
|
|
||||||
}) = server.this_user()
|
|
||||||
else {
|
|
||||||
return rsx!();
|
|
||||||
};
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
div {
|
|
||||||
class: "server_grid",
|
|
||||||
div {
|
|
||||||
class: "server_channel_box",
|
|
||||||
for (id, state) in server.channels_state.channels.iter() {
|
|
||||||
if state.parent.is_none() {
|
|
||||||
Channel { id: *id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "server_chat_box",
|
|
||||||
ChatView {}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "server_control_box",
|
|
||||||
ControlView { config }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn LoginView(config: Resource<ClientConfig>) -> Element {
|
|
||||||
let net: Coroutine<Command> = use_coroutine_handle();
|
|
||||||
|
|
||||||
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
|
|
||||||
use_resource(move || async move {
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
loop {
|
|
||||||
*last_status.write_unchecked() = Some(Platform::get_status(&client).await);
|
|
||||||
Platform::sleep(std::time::Duration::from_secs_f32(1.0)).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut address_input = use_signal(|| Platform::load_server_url());
|
|
||||||
let address = use_memo(move || {
|
|
||||||
if let Some(addr) = address_input() {
|
|
||||||
addr.clone()
|
|
||||||
} else {
|
|
||||||
config()
|
|
||||||
.and_then(|c| c.proxy_url.clone())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let previous_username = Platform::load_username();
|
|
||||||
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
|
|
||||||
|
|
||||||
let do_connect = move |_| {
|
|
||||||
//let _ = set_default_username(&username.read());
|
|
||||||
let _ = Platform::set_default_username(&username.read());
|
|
||||||
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
|
|
||||||
Platform::set_default_server(&address.read());
|
|
||||||
}
|
|
||||||
net.send(Connect {
|
|
||||||
address: address.read().clone(),
|
|
||||||
username: username.read().clone(),
|
|
||||||
config: config.read().clone().unwrap_or_default(),
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let status = &STATE.status;
|
|
||||||
let bottom = match &*status.read() {
|
|
||||||
Disconnected => rsx! {
|
|
||||||
button {
|
|
||||||
class: "login_bttn",
|
|
||||||
onclick: do_connect.clone(),
|
|
||||||
"Connect"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Connecting => rsx! {
|
|
||||||
div {
|
|
||||||
class: "login_bttn",
|
|
||||||
"Connecting..."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Failed(msg) => rsx!(
|
|
||||||
button {
|
|
||||||
class: "login_bttn",
|
|
||||||
onclick: do_connect.clone(),
|
|
||||||
"Reconnect"
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
class: "login_error",
|
|
||||||
"Failed to connect:"
|
|
||||||
pre {
|
|
||||||
"{msg}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
Connected => unreachable!(),
|
|
||||||
};
|
|
||||||
let version = option_env!("MUMBLE_WEB2_VERSION");
|
|
||||||
rsx!(
|
|
||||||
div {
|
|
||||||
class: "login",
|
|
||||||
h1 {
|
|
||||||
"Mumble Web"
|
|
||||||
match version {
|
|
||||||
Some(v) => rsx!(" " span { class: "login_version", "({v})" }),
|
|
||||||
None => rsx!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
|
|
||||||
div {
|
|
||||||
label {
|
|
||||||
for: "address-entry",
|
|
||||||
"Server Address:"
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
id: "address-entry",
|
|
||||||
placeholder: "address",
|
|
||||||
value: "{address.read()}",
|
|
||||||
autofocus: "true",
|
|
||||||
oninput: move |evt| address_input.set(Some(evt.value().clone())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
label {
|
|
||||||
for: "username-entry",
|
|
||||||
"Username:"
|
|
||||||
//style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
id: "username-entry",
|
|
||||||
placeholder: "username",
|
|
||||||
value: "{username.read()}",
|
|
||||||
autofocus: "true",
|
|
||||||
oninput: move |evt| username.set(evt.value().clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
match &*last_status.read() {
|
|
||||||
None => rsx!(div {
|
|
||||||
class: "login_status",
|
|
||||||
span {"···"}
|
|
||||||
}),
|
|
||||||
Some(Ok(ServerStatus { success: false, .. })) => rsx!(div {
|
|
||||||
class: "login_status is_error",
|
|
||||||
span {
|
|
||||||
"Could not reach server"
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Some(Ok(status)) => rsx!(div {
|
|
||||||
class: "login_status",
|
|
||||||
if let (Some(users), Some(max_users)) = (status.users, status.max_users) {
|
|
||||||
span {"{users}/{max_users} Online"}
|
|
||||||
} else {
|
|
||||||
span {"Unknown Online"}
|
|
||||||
}
|
|
||||||
span {"-"}
|
|
||||||
if let Some((maj, min, pat)) = status.version {
|
|
||||||
span {"Version: {maj}.{min}.{pat}"}
|
|
||||||
} else {
|
|
||||||
span {"Unknown Version"}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Some(Err(_)) => rsx!(div {
|
|
||||||
class: "login_status is_error",
|
|
||||||
span {
|
|
||||||
"Could not reach proxy server"
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
{bottom}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// rsx!(
|
|
||||||
// div {
|
|
||||||
// class: "{login_box}",
|
|
||||||
// h1 {
|
|
||||||
// "Mumble Web"
|
|
||||||
// }
|
|
||||||
// input {
|
|
||||||
// placeholder: "username",
|
|
||||||
// value: "{username.read()}",
|
|
||||||
// autofocus: "true",
|
|
||||||
// oninput: move |evt| username.set(evt.value().clone()),
|
|
||||||
// }
|
|
||||||
// input {
|
|
||||||
// placeholder: "server address",
|
|
||||||
// value: "{address.read()}",
|
|
||||||
// autofocus: "true",
|
|
||||||
// oninput: move |evt| address_input.set(Some(evt.value().clone())),
|
|
||||||
// }
|
|
||||||
// {bottom}
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn app() -> Element {
|
|
||||||
static STYLE: Asset = asset!("/assets/main.scss");
|
|
||||||
|
|
||||||
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
|
|
||||||
let config = use_resource(|| async move {
|
|
||||||
match Platform::load_config().await {
|
|
||||||
Ok(config) => config,
|
|
||||||
Err(_) => ClientConfig::default(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Platform::request_permissions();
|
|
||||||
|
|
||||||
rsx!(
|
|
||||||
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
|
|
||||||
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" }
|
|
||||||
document::Link{ rel: "stylesheet", href: STYLE }
|
|
||||||
|
|
||||||
match *STATE.status.read() {
|
|
||||||
Connected => rsx!(ServerView { config }),
|
|
||||||
_ => rsx!(LoginView { config }),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
use crate::app::Command;
|
|
||||||
use color_eyre::eyre::Error;
|
|
||||||
use dioxus::hooks::UnboundedReceiver;
|
|
||||||
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
|
|
||||||
use mumble_web2_common::{ClientConfig, ServerStatus};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
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;
|
|
||||||
|
|
||||||
async fn sleep(duration: Duration) {
|
|
||||||
tokio::time::sleep(duration).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_config() -> color_eyre::Result<ClientConfig> {
|
|
||||||
Ok(ClientConfig {
|
|
||||||
proxy_url: None,
|
|
||||||
cert_hash: None,
|
|
||||||
any_server: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_username() -> Option<String> {
|
|
||||||
let config = load_config_map();
|
|
||||||
config.get("username").cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_server_url() -> Option<String> {
|
|
||||||
let config = load_config_map();
|
|
||||||
config.get("server").cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_default_username(username: &str) -> Option<()> {
|
|
||||||
let mut config = load_config_map();
|
|
||||||
config.insert("username".to_string(), username.to_string());
|
|
||||||
save_config_map(&config).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_default_server(server: &str) -> Option<()> {
|
|
||||||
let mut config = load_config_map();
|
|
||||||
config.insert("server".to_string(), server.to_string());
|
|
||||||
save_config_map(&config).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
// No-op on desktop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_config_path() -> std::path::PathBuf {
|
|
||||||
let strategy = choose_app_strategy(AppStrategyArgs {
|
|
||||||
top_level_domain: "com".to_string(),
|
|
||||||
author: "Ohea Corp".to_string(),
|
|
||||||
app_name: "Mumble Web2".to_string(),
|
|
||||||
})
|
|
||||||
.expect("failed to choose app strategy");
|
|
||||||
strategy.config_dir().join("config.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_config_map() -> HashMap<String, String> {
|
|
||||||
let config_path = get_config_path();
|
|
||||||
match std::fs::read_to_string(&config_path) {
|
|
||||||
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
|
|
||||||
Err(_) => HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> {
|
|
||||||
let config_path = get_config_path();
|
|
||||||
if let Some(parent) = config_path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let contents = serde_json::to_string_pretty(config)?;
|
|
||||||
std::fs::write(&config_path, contents)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
+1206
-2
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -12,7 +12,7 @@ tokio-rustls = "0.26"
|
|||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tracing = { version = "^0.1.40", features = ["async-await"] }
|
tracing = { version = "^0.1.40", features = ["async-await"] }
|
||||||
tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] }
|
tracing-subscriber = { version = "^0.3.18", features = ["env-filter"] }
|
||||||
mumble-web2-common = { workspace = true }
|
mumble-web2-common = { workspace = true, features = ["networking"] }
|
||||||
salvo = { version = "^0.84.2", features = [
|
salvo = { version = "^0.84.2", features = [
|
||||||
"quinn",
|
"quinn",
|
||||||
"eyre",
|
"eyre",
|
||||||
@@ -28,4 +28,3 @@ rcgen = "^0.13.2"
|
|||||||
hmac-sha256 = "^1.1.8"
|
hmac-sha256 = "^1.1.8"
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
url = { version = "2", features = ["serde"] }
|
url = { version = "2", features = ["serde"] }
|
||||||
rand = "0.9.2"
|
|
||||||
|
|||||||
+16
-78
@@ -1,6 +1,5 @@
|
|||||||
use color_eyre::eyre::{anyhow, bail, Context, Result};
|
use color_eyre::eyre::{anyhow, bail, Context, Result};
|
||||||
use mumble_web2_common::{ClientConfig, ServerStatus};
|
use mumble_web2_common::{ping_server, ProxyOverrides, ServerStatus};
|
||||||
use rand::Rng;
|
|
||||||
use salvo::conn::rustls::{Keycert, RustlsConfig};
|
use salvo::conn::rustls::{Keycert, RustlsConfig};
|
||||||
use salvo::cors::{AllowOrigin, Cors};
|
use salvo::cors::{AllowOrigin, Cors};
|
||||||
use salvo::logging::Logger;
|
use salvo::logging::Logger;
|
||||||
@@ -16,7 +15,7 @@ use tokio::net::TcpStream;
|
|||||||
use tokio::pin;
|
use tokio::pin;
|
||||||
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
|
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
|
||||||
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||||
use tokio_rustls::rustls::{ClientConfig as RlsClientConfig, DigitallySignedStruct};
|
use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct};
|
||||||
use tokio_rustls::{rustls, TlsConnector};
|
use tokio_rustls::{rustls, TlsConnector};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing::info_span;
|
use tracing::info_span;
|
||||||
@@ -26,8 +25,6 @@ use tracing_subscriber::filter::LevelFilter;
|
|||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
mod ping;
|
|
||||||
|
|
||||||
fn default_cert_alt_names() -> Vec<String> {
|
fn default_cert_alt_names() -> Vec<String> {
|
||||||
vec!["localhost".into()]
|
vec!["localhost".into()]
|
||||||
}
|
}
|
||||||
@@ -77,7 +74,7 @@ async fn main() -> Result<()> {
|
|||||||
.install_default()
|
.install_default()
|
||||||
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?;
|
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?;
|
||||||
|
|
||||||
let mut client_config = ClientConfig {
|
let mut overrides = ProxyOverrides {
|
||||||
proxy_url: match &server_config.proxy_url {
|
proxy_url: match &server_config.proxy_url {
|
||||||
Some(url) => Some(url.to_string()),
|
Some(url) => Some(url.to_string()),
|
||||||
None => None,
|
None => None,
|
||||||
@@ -102,7 +99,7 @@ async fn main() -> Result<()> {
|
|||||||
let cert = cert_params.self_signed(&key_pair)?;
|
let cert = cert_params.self_signed(&key_pair)?;
|
||||||
|
|
||||||
let hash = hmac_sha256::Hash::hash(cert.der().as_ref());
|
let hash = hmac_sha256::Hash::hash(cert.der().as_ref());
|
||||||
client_config.cert_hash = Some(hash.into());
|
overrides.cert_hash = Some(hash.into());
|
||||||
|
|
||||||
(cert.pem().into(), key_pair.serialize_pem().into())
|
(cert.pem().into(), key_pair.serialize_pem().into())
|
||||||
}
|
}
|
||||||
@@ -122,14 +119,11 @@ async fn main() -> Result<()> {
|
|||||||
};
|
};
|
||||||
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
|
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
|
||||||
|
|
||||||
info!(
|
info!("proxy overrides:\n{}", toml::to_string_pretty(&overrides)?);
|
||||||
"client config:\n{}",
|
|
||||||
toml::to_string_pretty(&client_config)?
|
|
||||||
);
|
|
||||||
|
|
||||||
let config_craft = ConfigCraft {
|
let config_craft = ConfigCraft {
|
||||||
server_config: server_config.clone(),
|
server_config: server_config.clone(),
|
||||||
client_config,
|
overrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
let status_craft = StatusCraft {
|
let status_craft = StatusCraft {
|
||||||
@@ -139,7 +133,7 @@ async fn main() -> Result<()> {
|
|||||||
// Server routing
|
// Server routing
|
||||||
let mut router = Router::new()
|
let mut router = Router::new()
|
||||||
.push(Router::with_path("/proxy").goal(config_craft.connect_proxy()))
|
.push(Router::with_path("/proxy").goal(config_craft.connect_proxy()))
|
||||||
.push(Router::with_path("/config").get(config_craft.get_config()))
|
.push(Router::with_path("/overrides").get(config_craft.get_overrides()))
|
||||||
.push(Router::with_path("/status").get(status_craft.get_status()))
|
.push(Router::with_path("/status").get(status_craft.get_status()))
|
||||||
.hoop(Logger::new());
|
.hoop(Logger::new());
|
||||||
if let Some(gui_path) = server_config.gui_path.clone() {
|
if let Some(gui_path) = server_config.gui_path.clone() {
|
||||||
@@ -182,84 +176,28 @@ pub struct StatusCraft {
|
|||||||
impl StatusCraft {
|
impl StatusCraft {
|
||||||
#[craft(handler)]
|
#[craft(handler)]
|
||||||
async fn get_status(&self) -> Json<ServerStatus> {
|
async fn get_status(&self) -> Json<ServerStatus> {
|
||||||
let mut server_status = ServerStatus::default();
|
let addr = self.mumble_server_address;
|
||||||
|
match ping_server(&addr.ip().to_string(), addr.port()).await {
|
||||||
let ping_packet = ping::PingPacket {
|
Ok(status) => Json(status),
|
||||||
id: rand::rng().random(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let sock = match tokio::net::UdpSocket::bind("0.0.0.0:0").await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Could not bind udp socket: {}", e);
|
error!("ping failed: {e:#}");
|
||||||
return Json(server_status);
|
Json(ServerStatus::default())
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match sock.connect(self.mumble_server_address).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Could not send ping packet: {}", e);
|
|
||||||
return Json(server_status);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match sock.send(&<[u8; 12]>::from(ping_packet)).await {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Could not send ping packet");
|
|
||||||
return Json(server_status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pong_buf: [u8; 24] = [0; 24];
|
|
||||||
|
|
||||||
match tokio::time::timeout(
|
|
||||||
tokio::time::Duration::from_secs(1),
|
|
||||||
sock.recv(&mut pong_buf),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Could not send ping packet");
|
|
||||||
return Json(server_status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let pong_packet = match ping::PongPacket::try_from(pong_buf.as_slice()) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Could not parse pong packet: {:?}", e);
|
|
||||||
return Json(server_status);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
server_status.success = true;
|
|
||||||
server_status.version = Some((
|
|
||||||
pong_packet.version & 0xFF,
|
|
||||||
(pong_packet.version >> 8) & 0xFF,
|
|
||||||
(pong_packet.version >> 16) & 0xFF,
|
|
||||||
));
|
|
||||||
server_status.users = Some(pong_packet.users);
|
|
||||||
server_status.max_users = Some(pong_packet.max_users);
|
|
||||||
server_status.bandwidth = Some(pong_packet.bandwidth);
|
|
||||||
|
|
||||||
Json(server_status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ConfigCraft {
|
pub struct ConfigCraft {
|
||||||
server_config: Arc<Config>,
|
server_config: Arc<Config>,
|
||||||
client_config: ClientConfig,
|
overrides: ProxyOverrides,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[craft]
|
#[craft]
|
||||||
impl ConfigCraft {
|
impl ConfigCraft {
|
||||||
#[craft(handler)]
|
#[craft(handler)]
|
||||||
async fn get_config(&self) -> Json<ClientConfig> {
|
async fn get_overrides(&self) -> Json<ProxyOverrides> {
|
||||||
Json(self.client_config.clone())
|
Json(self.overrides.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[craft(handler)]
|
#[craft(handler)]
|
||||||
@@ -320,7 +258,7 @@ async fn connect_proxy_impl(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
info!("connecting to Mumble server...");
|
info!("connecting to Mumble server...");
|
||||||
|
|
||||||
let config = RlsClientConfig::builder()
|
let config = ClientConfig::builder()
|
||||||
.dangerous()
|
.dangerous()
|
||||||
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
||||||
.with_no_client_auth();
|
.with_no_client_auth();
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
// This code was taken from mumble-protocol-2x (https://github.com/dblsaiko/rust-mumble-protocol)
|
|
||||||
// and originally from mumble-protocol (https://github.com/Johni0702/rust-mumble-protocol)
|
|
||||||
// These projects are licensed under MIT and Apache 2.0.
|
|
||||||
|
|
||||||
//! Ping messages and codec
|
|
||||||
//!
|
|
||||||
//! A Mumble client can send periodic UDP [PingPacket]s to servers
|
|
||||||
//! in order to query their current state and measure latency.
|
|
||||||
//! A server will usually respond with a corresponding [PongPacket] containing
|
|
||||||
//! the requested details.
|
|
||||||
//!
|
|
||||||
//! Both packets are of fixed size and can be converted to/from `u8` arrays/slices via
|
|
||||||
//! the respective `From`/`TryFrom` impls.
|
|
||||||
|
|
||||||
/// A ping packet sent to the server.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct PingPacket {
|
|
||||||
/// Opaque, client-generated id.
|
|
||||||
///
|
|
||||||
/// Will be returned by the server unmodified and can be used to correlate
|
|
||||||
/// pong replies to ping requests to e.g. calculate latency.
|
|
||||||
pub id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A pong packet sent to the client in reply to a previously received [PingPacket].
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct PongPacket {
|
|
||||||
/// Opaque, client-generated id.
|
|
||||||
///
|
|
||||||
/// Should match the value in the corresponding [PingPacket].
|
|
||||||
pub id: u64,
|
|
||||||
|
|
||||||
/// Server version. E.g. `0x010300` for `1.3.0`.
|
|
||||||
pub version: u32,
|
|
||||||
|
|
||||||
/// Current amount of users connected to the server.
|
|
||||||
pub users: u32,
|
|
||||||
|
|
||||||
/// Configured limit on the amount of users which can be connected to the server.
|
|
||||||
pub max_users: u32,
|
|
||||||
|
|
||||||
/// Maximum bandwidth for server-bound speech per client in bits per second
|
|
||||||
pub bandwidth: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error during parsing of a [PingPacket].
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub enum ParsePingError {
|
|
||||||
/// Ping packets must always be 12 bytes in size.
|
|
||||||
InvalidSize,
|
|
||||||
/// Ping packets must have an all zero header of 4 bytes.
|
|
||||||
InvalidHeader,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<&[u8]> for PingPacket {
|
|
||||||
type Error = ParsePingError;
|
|
||||||
fn try_from(buf: &[u8]) -> Result<Self, Self::Error> {
|
|
||||||
match <[u8; 12]>::try_from(buf) {
|
|
||||||
Ok(array) => {
|
|
||||||
if array[0..4] != [0, 0, 0, 0] {
|
|
||||||
Err(ParsePingError::InvalidHeader)
|
|
||||||
} else {
|
|
||||||
Ok(Self {
|
|
||||||
id: u64::from_be_bytes(array[4..12].try_into().unwrap()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => Err(ParsePingError::InvalidSize),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<PingPacket> for [u8; 12] {
|
|
||||||
fn from(packet: PingPacket) -> Self {
|
|
||||||
let id = packet.id.to_be_bytes();
|
|
||||||
// Is there no nicer way to do this?
|
|
||||||
[
|
|
||||||
0, 0, 0, 0, id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error during parsing of a [PongPacket].
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub enum ParsePongError {
|
|
||||||
/// Pong packets must always be 24 bytes in size.
|
|
||||||
InvalidSize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<&[u8]> for PongPacket {
|
|
||||||
type Error = ParsePongError;
|
|
||||||
fn try_from(buf: &[u8]) -> Result<Self, Self::Error> {
|
|
||||||
match <[u8; 24]>::try_from(buf) {
|
|
||||||
Ok(array) => Ok(Self {
|
|
||||||
version: u32::from_be_bytes(array[0..4].try_into().unwrap()),
|
|
||||||
id: u64::from_be_bytes(array[4..12].try_into().unwrap()),
|
|
||||||
users: u32::from_be_bytes(array[12..16].try_into().unwrap()),
|
|
||||||
max_users: u32::from_be_bytes(array[16..20].try_into().unwrap()),
|
|
||||||
bandwidth: u32::from_be_bytes(array[20..24].try_into().unwrap()),
|
|
||||||
}),
|
|
||||||
Err(_) => Err(ParsePongError::InvalidSize),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<PongPacket> for [u8; 24] {
|
|
||||||
fn from(packet: PongPacket) -> Self {
|
|
||||||
let version = packet.version.to_be_bytes();
|
|
||||||
let id = packet.id.to_be_bytes();
|
|
||||||
let users = packet.users.to_be_bytes();
|
|
||||||
let max_users = packet.max_users.to_be_bytes();
|
|
||||||
let bandwidth = packet.bandwidth.to_be_bytes();
|
|
||||||
// Is there no nicer way to do this?
|
|
||||||
[
|
|
||||||
version[0],
|
|
||||||
version[1],
|
|
||||||
version[2],
|
|
||||||
version[3],
|
|
||||||
id[0],
|
|
||||||
id[1],
|
|
||||||
id[2],
|
|
||||||
id[3],
|
|
||||||
id[4],
|
|
||||||
id[5],
|
|
||||||
id[6],
|
|
||||||
id[7],
|
|
||||||
users[0],
|
|
||||||
users[1],
|
|
||||||
users[2],
|
|
||||||
users[3],
|
|
||||||
max_users[0],
|
|
||||||
max_users[1],
|
|
||||||
max_users[2],
|
|
||||||
max_users[3],
|
|
||||||
bandwidth[0],
|
|
||||||
bandwidth[1],
|
|
||||||
bandwidth[2],
|
|
||||||
bandwidth[3],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user