21 Commits

Author SHA1 Message Date
sam a25cf64681 salvo is working 2024-11-12 15:42:01 -07:00
sam 3c6a436690 wip salvo server 2024-11-11 17:14:53 -07:00
sam 80aedc7269 make proxy part of the project structure 2024-11-11 14:35:04 -07:00
sam efe842f671 Add 'proxy/' from commit 'e1f3bca708f1f5e8ecadc2becb95360a5a9ada13'
git-subtree-dir: proxy
git-subtree-mainline: 70fcd18690
git-subtree-split: e1f3bca708
2024-11-11 14:10:38 -07:00
sam 70fcd18690 actually make logging work 2024-11-11 14:09:05 -07:00
sam 2211be5324 bump dioxus version & icons in desktop & logging 2024-11-11 13:59:11 -07:00
sam 30a94323b3 move into gui folder for monorepo 2024-11-11 12:24:51 -07:00
sam e1f3bca708 built-in https 2024-11-09 14:34:12 -07:00
sam 7308a210e2 respect gui config 2024-11-09 14:08:37 -07:00
sam 105deab45d use config.toml & send gui config to client 2024-11-09 14:08:16 -07:00
restitux 4055bf24ab Add injected config file 2024-11-09 13:27:27 -07:00
restitux 95c57c4850 Add bundle script 2024-11-09 13:27:15 -07:00
restitux b19f629605 Add static asset serving 2024-11-09 12:50:56 -07:00
sam b351f2fe13 massive css rework 2024-11-09 00:58:25 -07:00
sam 30045dd6bd web-sys is optional 2024-11-09 00:58:25 -07:00
restitux 1d8f3fd791 cleanup 2024-08-30 23:18:22 -06:00
restitux 206bf23bdf Fix imports and try_write 2024-05-21 23:52:44 -06:00
restitux 2d3f31754b Add TCP connection to server 2024-05-21 23:29:13 -06:00
sam abd2a2f81c forgot length check 2024-05-21 18:10:17 -04:00
restitux b2cae01bf8 Add debug logging and global connection map 2024-05-21 00:22:41 -06:00
restitux 725db06703 Initial commit...it works? 2024-05-20 00:11:15 -06:00
26 changed files with 5794 additions and 1429 deletions
+14
View File
@@ -1,2 +1,16 @@
[build]
rustflags = ["--cfg=web_sys_unstable_apis"]
[profile]
[profile.dioxus-wasm]
inherits = "dev"
opt-level = 2
[profile.dioxus-server]
inherits = "dev"
opt-level = 2
[profile.dioxus-android]
inherits = "dev"
opt-level = 2
+2
View File
@@ -0,0 +1,2 @@
[language-server.rust-analyzer]
config = { cargo = { features = "all" } }
Generated
+2218 -1068
View File
File diff suppressed because it is too large Load Diff
+12 -86
View File
@@ -1,90 +1,16 @@
[package]
name = "mumble-web2"
version = "0.1.0"
edition = "2021"
[workspace]
resolver = "2"
members = [ "common","gui", "proxy"]
[dependencies]
dioxus = { version = "0.5.6" }
dioxus-web = { version = "0.5.6", optional = true }
manganis = "0.2.2"
once_cell = "1.19.0"
[workspace.dependencies]
serde = { version = "1.0.214", features = ["derive"] }
asynchronous-codec = "0.6.2"
futures = "0.3.30"
merge-io = "0.3.0"
mumble-protocol = { version = "0.5.0", package = "mumble-protocol-2x", default-features = false, features = [
"asynchronous-codec",
] }
serde_json = "1.0.117"
tokio-util = { version = "0.7.11", features = ["codec", "compat"] }
wasm-bindgen = { version = "0.2.92", optional = true }
wasm-bindgen-futures = { version = "0.4.42", optional = true }
wasm-streams = { version = "0.4.0", optional = true }
serde-wasm-bindgen = { version = "0.6.5", optional = true }
js-sys = { version = "0.3.70", optional = true }
web-sys = { version = "0.3.72", features = [
"WebTransport",
"console",
"WebTransportOptions",
"WebTransportBidirectionalStream",
"WebTransportSendStream",
"WebTransportReceiveStream",
"Navigator",
"MediaDevices",
"AudioDecoder",
"AudioDecoderInit",
"AudioData",
"AudioEncoderConfig",
"AudioDecoderConfig",
"EncodedAudioChunk",
"EncodedAudioChunkInit",
"EncodedAudioChunkType",
"CodecState",
"MediaStreamTrackGenerator",
"MediaStreamTrackGeneratorInit",
"AudioContext",
"AudioContextOptions",
"MediaStream",
"GainNode",
"MediaStreamAudioSourceNode",
"BaseAudioContext",
"AudioDestinationNode",
"AudioWorkletNode",
"AudioWorklet",
"AudioWorkletProcessor",
"MediaStreamConstraints",
"WorkletOptions",
"AudioEncoder",
"AudioEncoderInit",
"AudioDataInit",
"HtmlAnchorElement",
"Url",
"Blob",
"AudioDataCopyToOptions",
"AudioSampleFormat",
"Storage",
] }
anyhow = "1.0.86"
byteorder = "1.5.0"
ogg = "0.9.1"
ordermap = "0.5.3"
html-purifier = "0.3.0"
markdown = "0.3.0"
gloo-timers = { version = "0.3.0", features = ["futures"], optional = true }
futures-channel = "0.3.30"
sir = { version = "0.5.0", features = ["dioxus"] }
tokio = { version = "1.41.1", features = ["net", "rt"], optional = true }
tokio-rustls = { version = "0.26.0", optional = true }
mumble-web2-common = { path = "common" }
[features]
web = [
"dioxus/web",
"dioxus-web",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"serde-wasm-bindgen",
"js-sys",
"web-sys",
"gloo-timers",
[workspace.dependencies.mumble-protocol]
version = "0.5.0"
package = "mumble-protocol-2x"
default-features = false
features = [
"asynchronous-codec",
]
desktop = ["dioxus/desktop", "tokio", "tokio-rustls"]
+24
View File
@@ -0,0 +1,24 @@
# GUI Development
## Running Desktop
1. `cargo install dioxus-cli --version 0.6.0-alpha.4`
2. `dx build -p mumble-web2-gui --platform desktop`
## Running Web
1. `cargo install dioxus-cli --version 0.6.0-alpha.4`
2. `cargo install cargo install wtransport --example gencert`
3. in the proxy directory:
1. `cp config.toml.example config.toml`
2. run `gencert` and copy the certificate hash into config.toml
3. `cargo run -p mumble-web2-proxy` in the background
## with `dx serve`
4. in the gui directory
1. `export 'MUMBLE_WEB2_GUI_CONFIG={"cert_hash": <CERTIFICATE HASH HERE>, "proxy_url": "https://localhost:4433"}'`
2. `dx serve -p mumble-web2-gui --platform web`
5. connect to `localhost:8080` (most common)
## with `mumble-web2-proxy` only
4. in the gui directory:
1. `dx build -p mumble-web2-gui --platform web`
5. connect to `localhost:4434` (most common)
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "mumble-web2-common"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
+9
View File
@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Deserialize, Serialize, Default)]
pub struct GuiConfig {
#[serde(default)]
pub force_proxy: bool,
pub proxy_url: Option<String>,
pub cert_hash: Option<Vec<u8>>,
}
+103
View File
@@ -0,0 +1,103 @@
[package]
name = "mumble-web2-gui"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web Dependencies
# ================
dioxus-web = { version = "0.6.0-alpha.4", optional = true }
wasm-bindgen = { version = "0.2.92", optional = true }
wasm-bindgen-futures = { version = "0.4.42", optional = true }
wasm-streams = { version = "0.4.0", optional = true }
serde-wasm-bindgen = { version = "0.6.5", optional = true }
js-sys = { version = "0.3.70", optional = true }
web-sys = { version = "0.3.72", features = [
"WebTransport",
"console",
"WebTransportOptions",
"WebTransportBidirectionalStream",
"WebTransportSendStream",
"WebTransportReceiveStream",
"Navigator",
"MediaDevices",
"AudioDecoder",
"AudioDecoderInit",
"AudioData",
"AudioEncoderConfig",
"AudioDecoderConfig",
"EncodedAudioChunk",
"EncodedAudioChunkInit",
"EncodedAudioChunkType",
"CodecState",
"MediaStreamTrackGenerator",
"MediaStreamTrackGeneratorInit",
"AudioContext",
"AudioContextOptions",
"MediaStream",
"GainNode",
"MediaStreamAudioSourceNode",
"BaseAudioContext",
"AudioDestinationNode",
"AudioWorkletNode",
"AudioWorklet",
"AudioWorkletProcessor",
"MediaStreamConstraints",
"WorkletOptions",
"AudioEncoder",
"AudioEncoderInit",
"AudioDataInit",
"HtmlAnchorElement",
"Url",
"Blob",
"AudioDataCopyToOptions",
"AudioSampleFormat",
"Storage",
], optional = true}
gloo-timers = { version = "0.3.0", features = ["futures"], optional = true }
tracing-web = { version = "0.1.3", optional = true }
# Desktop Dependecies
# ===================
dioxus-desktop = { version = "0.6.0-alpha.4", optional = true}
tokio = { version = "1.41.1", features = ["net", "rt"], optional = true }
tokio-rustls = { version = "0.26.0", optional = true }
# Base Dependencies
# ================
dioxus = { version = "0.6.0-alpha.4" }
once_cell = "1.19.0"
asynchronous-codec = { workspace = true }
futures = "0.3.30"
merge-io = "0.3.0"
mumble-protocol = { workspace = true }
serde_json = "1.0.117"
tokio-util = { version = "0.7.11", features = ["codec", "compat"] }
byteorder = "1.5.0"
ogg = "0.9.1"
ordermap = "0.5.3"
html-purifier = "0.3.0"
markdown = "0.3.0"
futures-channel = "0.3.30"
sir = { git = "https://gitlab.com/samsartor/sir", features = ["dioxus"] } # dioxus 0.6
mumble-web2-common = { workspace = true }
serde = { workspace = true }
tracing-subscriber = { version = "0.3.18", features = ["ansi"] }
tracing = "0.1.40"
color-eyre = "0.6.3"
[features]
web = [
"dioxus/web",
"dioxus-web",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"serde-wasm-bindgen",
"js-sys",
"web-sys",
"gloo-timers",
"tracing-web",
]
desktop = ["dioxus/desktop", "tokio", "tokio-rustls", "tracing-subscriber/env-filter"]
+3 -15
View File
@@ -1,46 +1,34 @@
[application]
# App (Project) Name
name = "Mumble Web 2"
# Dioxus App Default Platform
# desktop, web, mobile, ssr
default_platform = "web"
# `build` & `serve` dist path
out_dir = "dist"
# resource (public) file folder
asset_dir = "public"
[web.app]
# HTML title tag content
title = "mumble-web"
title = "Mumble Web 2"
base_path = "gui"
[web.watcher]
# when watcher trigger, regenerate the `index.html`
reload_html = true
# which files or dirs will be watcher monitoring
watch_path = ["src", "public"]
watch_path = ["src", "assets"]
# include `assets` in web platform
[web.resource]
# CSS style file
style = []
# Javascript code file
script = []
[web.resource.dev]
# serve: [dev-server] only
# CSS style file
style = []
# Javascript code file
script = []
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g fill="#fff">
<path d="M8 0c-.78 0-1.538.29-2.104.821a2.862 2.862 0 00-.627.857.75.75 0 001.354.644c.07-.147.17-.286.3-.407A1.578 1.578 0 018 1.5c.413 0 .8.154 1.078.415.276.26.422.601.422.946v3.443a.75.75 0 001.5 0V2.861c0-.775-.329-1.507-.896-2.04A3.077 3.077 0 008 0z"/>
<path fill-rule="evenodd" d="M5 6.06L1.22 2.28a.75.75 0 011.06-1.06l12.5 12.5a.75.75 0 11-1.06 1.06L11.338 12.4a5.575 5.575 0 01-2.588 1.05V14.5h1.75a.75.75 0 010 1.5h-5a.75.75 0 010-1.5h1.75v-1.05a5.553 5.553 0 01-3.131-1.514A5.3 5.3 0 012.5 8.135V6.75a.75.75 0 011.5 0v1.385a3.8 3.8 0 001.164 2.725A4.071 4.071 0 008 12c.815 0 1.602-.24 2.262-.677l-.726-.726A3.113 3.113 0 018 11c-.78 0-1.538-.29-2.104-.821A2.797 2.797 0 015 8.139V6.06zm1.5 1.5v.579c0 .345.146.686.422.946.278.26.665.415 1.078.415.134 0 .266-.016.392-.047L6.5 7.56z" clip-rule="evenodd"/>
<path d="M12.03 6.75a.75.75 0 011.5 0v1.385c0 .266-.02.53-.06.79a.75.75 0 11-1.483-.227c.029-.185.043-.374.043-.563V6.75z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g fill="#fff">
<path fill-rule="evenodd" d="M8 0c-.78 0-1.538.29-2.104.821A2.797 2.797 0 005 2.861V8.14c0 .775.328 1.507.896 2.04.566.53 1.323.821 2.104.821.78 0 1.538-.29 2.104-.821A2.797 2.797 0 0011 8.139V2.86c0-.775-.329-1.507-.896-2.04A3.077 3.077 0 008 0zM6.922 1.915A1.578 1.578 0 018 1.5c.413 0 .8.154 1.078.415.276.26.422.601.422.946V8.14c0 .345-.146.686-.422.946A1.578 1.578 0 018 9.5c-.413 0-.8-.154-1.078-.415-.276-.26-.422-.601-.422-.946V2.86c0-.345.146-.686.422-.946z" clip-rule="evenodd"/>
<path d="M4 6.75a.75.75 0 00-1.5 0v1.385a5.3 5.3 0 001.619 3.801A5.553 5.553 0 007.25 13.45v1.05H5.5a.75.75 0 000 1.5h5a.75.75 0 000-1.5H8.75v-1.05a5.553 5.553 0 003.131-1.514A5.3 5.3 0 0013.5 8.135V6.75a.75.75 0 00-1.5 0v1.385a3.8 3.8 0 01-1.164 2.725A4.071 4.071 0 018 12a4.071 4.071 0 01-2.836-1.14A3.8 3.8 0 014 8.135V6.75z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0355 8.52113C13.4261 8.1306 14.0674 8.12674 14.3881 8.57637C15.0882 9.55788 15.5 10.7592 15.5 12.0567C15.5 13.3541 15.0882 14.5554 14.3881 15.537C14.0674 15.9866 13.4261 15.9827 13.0355 15.5922C12.645 15.2017 12.6586 14.5725 12.9408 14.0978C13.296 13.5002 13.5 12.8023 13.5 12.0567C13.5 11.3111 13.296 10.6131 12.9408 10.0156C12.6586 9.54084 12.645 8.91165 13.0355 8.52113Z" fill="#fff"/>
<path d="M15.864 5.69316C16.2545 5.30263 16.8921 5.29976 17.2419 5.72712C18.6532 7.45118 19.5 9.65526 19.5 12.0571C19.5 14.459 18.6532 16.6631 17.2419 18.3871C16.8921 18.8145 16.2545 18.8116 15.864 18.4211C15.4734 18.0306 15.4792 17.4007 15.8183 16.9648C16.8723 15.6098 17.5 13.9068 17.5 12.0571C17.5 10.2075 16.8723 8.50445 15.8183 7.14944C15.4792 6.71351 15.4734 6.08368 15.864 5.69316Z" fill="#fff"/>
<path d="M11 16.5858V7.41421C11 6.52331 9.92286 6.07714 9.29289 6.70711L7.29289 8.70711C7.10536 8.89464 6.851 9 6.58579 9H5C4.44772 9 4 9.44772 4 10V14C4 14.5523 4.44772 15 5 15H6.58579C6.851 15 7.10536 15.1054 7.29289 15.2929L9.29289 17.2929C9.92286 17.9229 11 17.4767 11 16.5858Z" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 16.5858V7.41421C11 6.52331 9.92286 6.07714 9.29289 6.70711L7.29289 8.70711C7.10536 8.89464 6.851 9 6.58579 9H5C4.44772 9 4 9.44772 4 10V14C4 14.5523 4.44772 15 5 15H6.58579C6.851 15 7.10536 15.1054 7.29289 15.2929L9.29289 17.2929C9.92286 17.9229 11 17.4767 11 16.5858Z" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
<path d="M20 9.5L15 14.5" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
<path d="M20 14.5L15 9.5" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 737 B

+214 -63
View File
@@ -5,7 +5,7 @@ use ordermap::OrderSet;
use sir::{css, global_css};
use std::collections::HashMap;
use crate::imp;
use crate::{imp, CONFIG};
pub type ChannelId = u32;
pub type UserId = u32;
@@ -61,6 +61,16 @@ pub struct UserState {
pub self_mute: bool,
}
impl UserState {
pub fn icon(&self) -> UserIcon {
match (self.mute || self.self_mute, self.deaf || self.self_deaf) {
(false, false) => UserIcon::Normal,
(true, false) => UserIcon::Muted,
(_, true) => UserIcon::Deafened,
}
}
}
pub struct Chat {
pub raw: String,
pub dangerous_html: String,
@@ -91,21 +101,57 @@ pub static STATE: State = State {
server: Signal::global(|| Default::default()),
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum UserIcon {
Normal,
Muted,
Deafened,
None,
}
impl UserIcon {
pub fn url(self) -> Option<Asset> {
// speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/
// mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/
use UserIcon::*;
Some(match self {
Normal => asset!("assets/mic-svgrepo-com.svg"),
Muted => asset!("assets/mic-off-svgrepo-com.svg"),
Deafened => asset!("assets/speaker-muted-svgrepo-com.svg"),
None => return Option::None,
})
}
}
#[component]
pub fn UserPill(name: String) -> Element {
pub fn UserPill(name: String, icon: UserIcon) -> Element {
let pill = css!(
"
border: solid 1px black;
border-radius: 8px;
padding: 4px;
border-radius: 100px;
padding: 4px 8px;
width: fit-content;
img {
height: 1em;
vertical-align: text-bottom;
}
"
);
let color = match icon {
UserIcon::Normal => "var(--accent-a)",
UserIcon::Muted => "var(--accent-b)",
UserIcon::Deafened => "var(--accent-c)",
UserIcon::None => "var(--accent-a)",
};
rsx!(
div {
class: "{pill}",
"{name}"
style: "background-color: {color}",
{ icon.url().map(|url| rsx!(img { src: url })) }
"\u{00A0}{name}\u{00A0}"
}
)
}
@@ -113,34 +159,50 @@ pub fn UserPill(name: String) -> Element {
#[component]
pub fn User(id: UserId) -> Element {
let server = STATE.server.read();
let state = server.users.get(&id)?;
rsx!(UserPill {
name: state.name.clone()
})
match server.users.get(&id) {
Some(state) => rsx!(UserPill {
name: state.name.clone(),
icon: state.icon(),
}),
None => rsx!(UserPill {
name: format!("unknown user ({id})"),
icon: UserIcon::None,
}),
}
}
#[component]
pub fn Channel(id: ChannelId) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let user = server.session?;
let state = server.channels.get(&id)?;
let user = server.session.unwrap();
let Some(state) = server.channels.get(&id) else {
return rsx!("missing channel {id}");
};
let channel_details = css!(
"
flex: 0 0 100%;
summary {
cursor: pointer;
}
summary:focus-visible {
outline: none;
}
"
);
let channel_children = css!(
"
border-left: solid black 1px;
border-left: solid var(--line-color) var(--line-width);
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-left: 5px;
padding-left: 11px;
"
padding-top: 4px; "
);
rsx!(
@@ -148,16 +210,25 @@ pub fn Channel(id: ChannelId) -> Element {
class: "{channel_details}",
open: true,
summary {
ondoubleclick: move |_| net.send(EnterChannel { channel: id, user }),
"{state.name}"
}
div {
class: "{channel_children}",
for id in state.users.iter() {
User { id: *id }
span {
role: "button",
prevent_default: "onclick",
ondoubleclick: move |evt| {
evt.stop_propagation();
net.send(EnterChannel { channel: id, user })
},
"{state.name}"
}
for child in state.children.iter() {
Channel { id: *child }
}
if state.users.len() + state.children.len() > 0 {
div {
class: "{channel_children}",
for id in state.users.iter() {
User { id: *id }
}
for child in state.children.iter() {
Channel { id: *child }
}
}
}
}
@@ -193,7 +264,7 @@ pub fn ChatView() -> Element {
flex-direction: row;
padding: 16px;
gap: 8px;
border-top: solid black 1px;
border-top: solid var(--line-color) var(--line-width);
input {
flex-grow: 1;
@@ -218,7 +289,10 @@ pub fn ChatView() -> Element {
div {
class: "{chat_message}",
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
UserPill { name: sender.name.clone() }
UserPill {
name: sender.name.clone(),
icon: UserIcon::None,
}
}
span {
dangerous_inner_html: "{chat.dangerous_html}",
@@ -250,19 +324,22 @@ pub fn ChatView() -> Element {
pub fn ServerView() -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let &UserState {
let Some(&UserState {
deaf,
self_deaf,
mute,
self_mute,
..
} = server.this_user()?;
}) = server.this_user()
else {
return rsx!();
};
let grid = css!(
r#"
display: grid;
height: 100%;
background-color: white;
background-color: var(--bg-color);
grid-template-rows: auto 1fr;
grid-template-columns: 1fr 1fr;
@@ -278,16 +355,12 @@ pub fn ServerView() -> Element {
"tree"
"chat";
}
gap: 4px;
padding: 4px;
"#
);
let channel_box = css!(
"
padding: 16px;
border: solid black 1px;
overflow: auto;
grid-area: tree;
"
@@ -295,18 +368,23 @@ pub fn ServerView() -> Element {
let chat_box = css!(
"
border: solid black 1px;
display: flex;
flex-direction: column;
grid-area: chat;
border-left: solid var(--line-color) var(--line-width);
@media screen and (max-width: 720px) {
border-left:unset;
border-top: solid var(--line-color) var(--line-width);
}
"
);
let top_bar = css!(
"
padding: 16px;
border: solid black 1px;
grid-area: bar;
background-color: var(--login-bg-color);
display: flex;
flex-direction: row;
@@ -315,6 +393,11 @@ pub fn ServerView() -> Element {
button {
padding: 8px;
img {
height: 1em;
vertical-align: text-bottom;
}
}
"
);
@@ -328,31 +411,27 @@ pub fn ServerView() -> Element {
onclick: move |_| net.send(Disconnect),
"Disconnect"
}
span {
input {
r#type: "checkbox",
id: "mute",
checked: mute || self_mute,
disabled: mute,
onchange: move |_| net.send(SetMute { mute: !self_mute }),
}
label {
r#for: "mute",
"Mute"
button {
role: "switch",
aria_checked: mute || self_mute,
disabled: mute,
onclick: move |_| net.send(SetMute { mute: !self_mute }),
match mute || self_mute {
true => rsx!(img { src: asset!("assets/mic-off-svgrepo-com.svg") }),
false => rsx!(img { src: asset!("assets/mic-svgrepo-com.svg") }),
}
"\u{00A0}Mute"
}
span {
input {
r#type: "checkbox",
id: "deaf",
checked: deaf || self_deaf,
disabled: deaf,
onchange: move |_| net.send(SetDeaf { deaf: !self_deaf }),
}
label {
r#for: "deaf",
"Deafen"
button {
role: "switch",
aria_checked: deaf || self_deaf,
disabled: deaf,
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
match deaf || self_deaf {
true => rsx!(img { src: asset!("assets/speaker-muted-svgrepo-com.svg") }),
false => rsx!(img { src: asset!("assets/speaker-medium-svgrepo-com.svg") }),
}
"\u{00A0}Deafen"
}
}
div {
@@ -374,7 +453,7 @@ pub fn ServerView() -> Element {
#[component]
pub fn LoginView() -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let default_address = option_env!("MUMBLEWEB2_WEBTRANSPORT_SERVER_ADDRESS").unwrap_or("");
let default_address = CONFIG.proxy_url.as_deref().unwrap_or("");
let mut address = use_signal(|| default_address.to_string());
let previous_username = imp::load_username();
@@ -382,6 +461,10 @@ pub fn LoginView() -> Element {
let error = css!(
"
background-color: white;
border-radius: 4px;
overflow: auto;
padding: 4px;
color: red;
pre {
color: black;
@@ -393,8 +476,9 @@ pub fn LoginView() -> Element {
"
max-width: 50vw;
align-self: center;
padding: 16px;
background-color: white;
padding: 32px;
border-radius: 16px;
background-color: var(--login-bg-color);
display: flex;
flex-direction: column;
@@ -403,6 +487,18 @@ pub fn LoginView() -> Element {
input,button {
padding: 8px;
}
h1 {
margin: 0;
color: #b3c6b4;
}
"
);
let bttn = css!(
"
font-weight: bold;
font-size: large;
"
);
@@ -418,17 +514,22 @@ pub fn LoginView() -> Element {
let bottom = match &*status.read() {
Disconnected => rsx! {
button {
class: "{bttn}",
onclick: do_connect.clone(),
"Connect!"
"Connect"
}
},
Connecting => rsx! {
"Connecting..."
div {
class: "{bttn}",
"Connecting..."
}
},
Failed(msg) => rsx!(
button {
class: "{bttn}",
onclick: do_connect.clone(),
"Reconnect!"
"Reconnect"
}
div {
class: "{error}",
@@ -443,6 +544,9 @@ pub fn LoginView() -> Element {
rsx!(
div {
class: "{login_box}",
h1 {
"Mumble Web"
}
input {
placeholder: "username",
value: "{username.read()}",
@@ -465,6 +569,18 @@ pub fn app() -> Element {
global_css!(
"
:root {
--txt-color: white;
--bg-color: #372f3a;
--login-bg-color: #5d7680;
--primary-btn-color: #7bad9f;
--accent-a: #8eb29a;
--accent-b: #6a9395;
--accent-c: #464459;
--line-width: 2px;
--line-color: white;
}
body {
margin: 0;
}
@@ -474,8 +590,43 @@ pub fn app() -> Element {
display: flex;
flex-direction: column;
justify-content: space-around;
background-color: grey;
background-color: var(--bg-color);
overflow: auto;
color: var(--txt-color);
font-family: sans-serif;
font-size: large;
}
button {
font-weight: bold;
font-size: medium;
border: none;
border-radius: 4px;
color: var(--txt-color);
background-color: var(--primary-btn-color);
cursor: pointer;
}
input {
border: none;
border-radius: 4px;
background-color: white;
color: black;
}
input:focus,input:focus-visible {
border: none;
outline: solid var(--line-width) var(--accent-a);
outline-offset: -3px;
}
a:link {
color: var(--accent-a);
}
a:visited {
color: var(--accent-b);
}
"
);
+28 -47
View File
@@ -1,13 +1,13 @@
use crate::app::Command;
use anyhow::Result;
use color_eyre::eyre::Error;
use dioxus::hooks::{UnboundedReceiver, UnboundedSender};
use futures::io::{AsyncRead, AsyncWrite};
use mumble_protocol::control::{ClientControlCodec, ControlPacket};
use mumble_protocol::Serverbound;
use mumble_web2_common::GuiConfig;
use std::net::ToSocketAddrs;
use std::{fmt, io, sync::Arc};
use tokio::net::TcpStream;
use tokio::task::LocalSet;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
@@ -19,60 +19,22 @@ use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt
pub use tokio::task::spawn;
pub use tokio::time::sleep;
pub struct Error(anyhow::Error);
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
impl From<anyhow::Error> for Error {
fn from(value: anyhow::Error) -> Self {
Error(value)
}
}
impl From<io::Error> for Error {
fn from(value: io::Error) -> Self {
Error(value.into())
}
}
impl Error {
pub fn new(text: String) -> Self {
Self(anyhow::Error::msg(text))
}
pub fn log(&self) {
eprintln!("{}", self.0);
}
}
impl std::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
pub struct AudioSystem();
impl AudioSystem {
pub fn new(sender: UnboundedSender<ControlPacket<Serverbound>>) -> Result<Self, Error> {
// dbg!("todo");
// TODO
Ok(AudioSystem())
}
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
// dbg!("todo");
// TODO
Ok(AudioPlayer())
}
}
@@ -81,7 +43,7 @@ pub struct AudioPlayer();
impl AudioPlayer {
pub fn play_opus(&mut self, payload: &[u8]) {
// dbg!("todo");
// TODO
}
}
@@ -157,17 +119,17 @@ pub async fn network_connect(
let server_tcp = TcpStream::connect(addr).await?;
let server_stream = connector
//.connect("127.0.0.1".try_into()?, server_tcp)
.connect(address.try_into().map_err(anyhow::Error::from)?, server_tcp)
.connect(address.try_into()?, server_tcp)
.await?;
let (read_server, write_server) = tokio::io::split(server_stream);
let read_codec = ClientControlCodec::new();
let write_codec = ClientControlCodec::new();
let mut reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let mut writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
let reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
let writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
super::network_loop(username, event_rx, reader, writer).await
crate::network_loop(username, event_rx, reader, writer).await
}
pub fn set_default_username(username: &str) -> Option<()> {
@@ -177,3 +139,22 @@ pub fn set_default_username(username: &str) -> Option<()> {
pub fn load_username() -> Option<String> {
return None;
}
pub fn load_config() -> Option<GuiConfig> {
None
}
pub fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
+11
View File
@@ -0,0 +1,11 @@
#[cfg(feature = "web")]
mod web;
#[cfg(feature = "desktop")]
mod desktop;
#[cfg(all(feature = "web", not(feature = "desktop")))]
pub use web::*;
#[cfg(feature = "desktop")]
pub use desktop::*;
+139 -124
View File
@@ -1,25 +1,19 @@
use crate::app::Command;
use crate::bail;
use crate::CONFIG;
use color_eyre::eyre::{bail, eyre, Error};
use dioxus::prelude::*;
use futures::AsyncRead;
use futures::AsyncWrite;
use futures::{AsyncRead, AsyncWrite};
use futures_channel::mpsc::UnboundedSender;
use gloo_timers::future::TimeoutFuture;
use mumble_protocol::control::ClientControlCodec;
use mumble_protocol::control::ControlPacket;
use mumble_protocol::voice::VoicePacket;
use mumble_protocol::voice::VoicePacketPayload;
use mumble_protocol::control::{ClientControlCodec, ControlPacket};
use mumble_protocol::voice::{VoicePacket, VoicePacketPayload};
use mumble_protocol::Serverbound;
use std::fmt;
use std::io;
use mumble_web2_common::GuiConfig;
use std::time::Duration;
use tracing::{debug, error, info, instrument};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::console;
use web_sys::js_sys::Promise;
use web_sys::js_sys::Reflect;
use web_sys::js_sys::Uint8Array;
use web_sys::window;
use web_sys::js_sys::{Promise, Reflect, Uint8Array};
use web_sys::AudioContext;
use web_sys::AudioContextOptions;
use web_sys::AudioData;
@@ -42,6 +36,7 @@ use web_sys::WebTransport;
use web_sys::WebTransportBidirectionalStream;
use web_sys::WebTransportOptions;
use web_sys::WorkletOptions;
use web_sys::{console, window};
pub use wasm_bindgen_futures::spawn_local as spawn;
@@ -55,58 +50,27 @@ pub async fn sleep(d: Duration) {
TimeoutFuture::new(d.as_millis() as u32).await
}
pub struct Error(JsValue);
trait ResultExt<T> {
fn ey(self) -> Result<T, Error>;
}
impl From<anyhow::Error> for Error {
fn from(value: anyhow::Error) -> Self {
Error(JsError::new(&value.to_string()).into())
impl<T> ResultExt<T> for Result<T, JsValue> {
fn ey(self) -> Result<T, Error> {
match self {
Ok(x) => Ok(x),
Err(e) => match e.dyn_into::<js_sys::Error>() {
Ok(e) => Err(eyre!("{}: {}", e.name(), e.message())),
Err(e) => Err(eyre!("{:?}", e)),
},
}
}
}
impl From<io::Error> for Error {
fn from(value: io::Error) -> Self {
Error(JsError::new(&value.to_string()).into())
impl<T> ResultExt<T> for Result<T, JsError> {
fn ey(self) -> Result<T, Error> {
self.map_err(|e| JsValue::from(e)).ey()
}
}
impl From<JsValue> for Error {
fn from(value: JsValue) -> Self {
Error(value)
}
}
impl From<JsError> for Error {
fn from(value: JsError) -> Self {
Error(JsError::from(value).into())
}
}
impl Error {
pub fn new(text: String) -> Self {
wasm_bindgen::JsError::new(&text).into()
}
pub fn log(&self) {
console::error_1(&self.0);
}
}
impl std::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text: String = js_sys::Object::from(self.0.clone()).to_string().into();
f.write_str(&text)
}
}
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text: String = js_sys::Object::from(self.0.clone()).to_string().into();
f.write_str(&text)
}
}
pub struct AudioSystem(AudioContext);
impl AudioSystem {
@@ -118,8 +82,8 @@ impl AudioSystem {
let audio_context_worklet = audio_context.clone();
spawn(async move {
match create_encoder_worklet(&audio_context_worklet, sender).await {
Ok(node) => console::log_2(&"Created audio worklet:".into(), &node),
Err(err) => err.log(),
Ok(node) => info!("created encoder worklet: {:?}", &node),
Err(err) => error!("could not create encoder worklet: {err}"),
}
});
@@ -130,21 +94,25 @@ impl AudioSystem {
let audio_context = &self.0;
let audio_stream_generator =
MediaStreamTrackGenerator::new(&MediaStreamTrackGeneratorInit::new("audio"))?;
MediaStreamTrackGenerator::new(&MediaStreamTrackGeneratorInit::new("audio")).ey()?;
// Create MediaStream from MediaStreamTrackGenerator
let js_tracks = web_sys::js_sys::Array::new();
js_tracks.push(&audio_stream_generator);
let media_stream = MediaStream::new_with_tracks(&js_tracks)?;
let media_stream = MediaStream::new_with_tracks(&js_tracks).ey()?;
// Create MediaStreamAudioSourceNode
let audio_source = audio_context.create_media_stream_source(&media_stream)?;
let audio_source = audio_context
.create_media_stream_source(&media_stream)
.ey()?;
// Connect output of audio_source to audio_context (browser audio)
audio_source.connect_with_audio_node(&audio_context.destination())?;
audio_source
.connect_with_audio_node(&audio_context.destination())
.ey()?;
// Create callback functions for AudioDecoder
let error = Closure::wrap(Box::new(move |e: JsValue| {
console::error_1(&e);
let decoder_error = Closure::wrap(Box::new(move |e: JsValue| {
error!("error decoding audio {:?}", e);
}) as Box<dyn FnMut(JsValue)>);
// This knows what MediaStreamTrackGenerator to use as it closes around it
@@ -155,29 +123,33 @@ impl AudioSystem {
}
if let Err(e) = writable.get_writer().map(|writer| {
spawn(async move {
if let Err(e) = JsFuture::from(writer.ready()).await {
console::error_1(&format!("write chunk ready error {:?}", e).into());
if let Err(e) = JsFuture::from(writer.ready()).await.ey() {
error!("write chunk ready error {:?}", e);
}
if let Err(e) = JsFuture::from(writer.write_with_chunk(&audio_data)).await {
console::error_1(&format!("write chunk error {:?}", e).into());
if let Err(e) = JsFuture::from(writer.write_with_chunk(&audio_data))
.await
.ey()
{
error!("write chunk error {:?}", e);
};
writer.release_lock();
});
}) {
console::error_1(&e);
error!("error writing audio data {:?}", e);
}
}) as Box<dyn FnMut(AudioData)>);
let audio_decoder = AudioDecoder::new(&AudioDecoderInit::new(
error.as_ref().unchecked_ref(),
decoder_error.as_ref().unchecked_ref(),
output.as_ref().unchecked_ref(),
))?;
))
.ey()?;
audio_decoder.configure(&AudioDecoderConfig::new("opus", 1, 48000));
console::log_1(&"Created Audio Decoder".into());
info!("created audio decoder");
// This is required to prevent these from being deallocated
error.forget();
decoder_error.forget();
output.forget();
Ok(AudioPlayer(audio_decoder))
@@ -226,32 +198,41 @@ async fn create_encoder_worklet(
let stream = window()
.unwrap()
.navigator()
.media_devices()?
.get_user_media_with_constraints(MediaStreamConstraints::new().audio(&JsValue::TRUE))?
.media_devices()
.ey()?
.get_user_media_with_constraints(MediaStreamConstraints::new().audio(&JsValue::TRUE))
.ey()?
.into_future()
.await?
.await
.ey()?
.dyn_into()
.map_err(|e| JsError::new(&format!("not a stream: {e:?}")))?;
.map_err(|e| JsError::new(&format!("not a stream: {e:?}")))
.ey()?;
let options = WorkletOptions::new();
Reflect::set(
&options,
&"processorOptions".into(),
&wasm_bindgen::module(),
)?;
)
.ey()?;
let module = "rust_mic_worklet.js";
console::log_1(&format!("Loading mic worklet from {module:?}").into());
let module = asset!("assets/rust_mic_worklet.js").to_string();
info!("loading mic worklet from {module:?}");
audio_context
.audio_worklet()?
.add_module_with_options(module, &options)?
.audio_worklet()
.ey()?
.add_module_with_options(&module, &options)
.ey()?
.into_future()
.await?;
.await
.ey()?;
let source = audio_context.create_media_stream_source(&stream)?;
let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet")?;
let source = audio_context.create_media_stream_source(&stream).ey()?;
let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet").ey()?;
let error: Closure<dyn FnMut(JsValue)> = Closure::new(|e| console::error_1(&e));
let encoder_error: Closure<dyn FnMut(JsValue)> =
Closure::new(|e| error!("error encoding audio {:?}", e));
let download_buffer = std::cell::RefCell::new(Vec::new());
@@ -285,13 +266,13 @@ async fn create_encoder_worklet(
});
let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new(
error.as_ref().unchecked_ref(),
encoder_error.as_ref().unchecked_ref(),
output.as_ref().unchecked_ref(),
))
.unwrap();
// This is required to prevent these from being deallocated
error.forget();
encoder_error.forget();
output.forget();
let encoder_config = AudioEncoderConfig::new("opus");
encoder_config.set_number_of_channels(1);
@@ -299,7 +280,7 @@ async fn create_encoder_worklet(
encoder_config.set_bitrate(72_000.0);
audio_encoder.configure(&encoder_config);
console::log_1(&"Created Audio Encoder".into());
info!("created audio encoder");
let download_buffer = std::cell::RefCell::new(Vec::new());
@@ -320,41 +301,36 @@ async fn create_encoder_worklet(
audio_encoder.encode(&data);
}
Err(err) => {
console::error_1(&err);
console::debug_1(&event);
error!(
"error creating AudioData object {:?} during event {:?}",
err, event,
);
}
}
});
Reflect::set(
&Reflect::get(&worklet_node, &"port".into())?,
&Reflect::get(&worklet_node, &"port".into()).ey()?,
&"onmessage".into(),
onmessage.as_ref(),
)?;
)
.ey()?;
onmessage.forget();
source.connect_with_audio_node(&worklet_node)?;
worklet_node.connect_with_audio_node(&audio_context.destination())?;
source.connect_with_audio_node(&worklet_node).ey()?;
worklet_node
.connect_with_audio_node(&audio_context.destination())
.ey()?;
Ok(worklet_node)
}
#[instrument]
pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
) -> Result<(), Error> {
console::log_1(&"Rust via WASM!".into());
let Ok(server_hash): Result<Vec<u8>, _> = include_str!("../../server_hash.txt")
.trim()
.trim_matches(&['[', ']'])
.split(',')
.map(|x| x.trim().parse())
.collect()
else {
bail!("could not parse server hash");
};
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
info!("Rust via WASM!");
let object = web_sys::js_sys::Object::new();
@@ -362,34 +338,42 @@ pub async fn network_connect(
&object,
&JsValue::from_str("algorithm"),
&JsValue::from_str("sha-256"),
)?;
)
.ey()?;
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash)?;
if let Some(server_hash) = &CONFIG.cert_hash {
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?;
}
let array = web_sys::js_sys::Array::new();
array.push(&object);
console::log_1(&object.clone().into());
console::log_1(&"Created option object!".into());
debug!("created option object: {:?}", &object);
let mut options = WebTransportOptions::new();
options.server_certificate_hashes(&array);
options.set_server_certificate_hashes(&array);
console::log_1(&"Created WebTransportOptions!".into());
debug!("created WebTransportOptions");
console::log_1(&options.clone().into());
let transport = WebTransport::new_with_options(&address, &options)?;
console::log_1(&"Created WebTransport connection object.".into());
let transport = WebTransport::new_with_options(&address, &options).ey()?;
debug!("created WebTransport connection object");
console::log_1(&transport.clone().into());
if let Err(e) = wasm_bindgen_futures::JsFuture::from(transport.ready()).await {
bail!("could not connect to transport: {e:?}");
if let Err(e) = wasm_bindgen_futures::JsFuture::from(transport.ready())
.await
.ey()
{
bail!("could not connect to transport: {e}");
}
console::log_1(&"Transport is ready.".into());
info!("transport is ready");
let stream: WebTransportBidirectionalStream =
wasm_bindgen_futures::JsFuture::from(transport.create_bidirectional_stream())
.await?
.await
.ey()?
.into();
let wasm_stream_readable = wasm_streams::ReadableStream::from_raw(stream.readable().into());
@@ -403,7 +387,7 @@ pub async fn network_connect(
let writer =
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
super::network_loop(username, event_rx, reader, writer).await
crate::network_loop(username, event_rx, reader, writer).await
}
pub fn set_default_username(username: &str) -> Option<()> {
@@ -422,3 +406,34 @@ pub fn load_username() -> Option<String> {
.get_item("username")
.ok()?
}
fn load_config_from_window() -> Option<GuiConfig> {
serde_wasm_bindgen::from_value(Reflect::get(window()?.as_ref(), &"config".into()).ok()?).ok()
}
fn load_config_from_env() -> Option<GuiConfig> {
serde_json::from_str(option_env!("MUMBLE_WEB2_GUI_CONFIG")?).ok()?
}
pub fn load_config() -> Option<GuiConfig> {
load_config_from_window().or_else(load_config_from_env)
}
pub fn init_logging() {
// copied from tracing_web example usage
use tracing_subscriber::fmt::format::Pretty;
use tracing_subscriber::prelude::*;
use tracing_web::{performance_layer, MakeWebConsoleWriter};
let fmt_layer = tracing_subscriber::fmt::layer()
.with_ansi(false) // Only partially supported across browsers
.without_time() // std::time is not available in browsers
.with_writer(MakeWebConsoleWriter::new()); // write events to the console
let perf_layer = performance_layer().with_details_from_fields(Pretty::default());
tracing_subscriber::registry()
.with(fmt_layer)
.with(perf_layer)
.init();
}
+19 -25
View File
@@ -4,6 +4,7 @@ use app::ConnectionState;
use app::STATE;
use asynchronous_codec::FramedRead;
use asynchronous_codec::FramedWrite;
use color_eyre::eyre::{bail, Error};
use dioxus::prelude::*;
use futures::select;
use futures::FutureExt as _;
@@ -11,44 +12,36 @@ use futures::SinkExt as _;
use futures::StreamExt as _;
use futures_channel::mpsc::UnboundedSender;
pub use imp::spawn;
pub use imp::Error;
use mumble_protocol::control::msgs;
use mumble_protocol::control::ControlCodec;
use mumble_protocol::control::ControlPacket;
use mumble_protocol::voice::VoicePacketPayload;
use mumble_protocol::Clientbound;
use mumble_protocol::Serverbound;
use mumble_web2_common::GuiConfig;
use once_cell::sync::Lazy;
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::time::Duration;
use tracing::debug;
use tracing::error;
use tracing::info;
pub mod app;
#[cfg(feature = "web")]
#[path = "imp/web.rs"]
pub mod imp;
#[cfg(feature = "desktop")]
#[path = "imp/desktop.rs"]
pub mod imp;
#[macro_export]
macro_rules! bail {
($($x:tt)*) => {
return Err(Error::new(format!($($x)*)))
};
}
pub static CONFIG: Lazy<GuiConfig> = Lazy::new(|| imp::load_config().unwrap_or_default());
pub async fn network_entrypoint(mut event_rx: UnboundedReceiver<Command>) {
loop {
let Some(Command::Connect { address, username }) = event_rx.next().await else {
panic!("Did not receive connect command")
panic!("did not receive connect command")
};
*STATE.server.write() = Default::default();
*STATE.status.write() = ConnectionState::Connecting;
if let Err(error) = imp::network_connect(address, username, &mut event_rx).await {
error.log();
error!("could not connect {:?}", error);
*STATE.status.write() = ConnectionState::Failed(error.to_string());
} else {
*STATE.status.write() = ConnectionState::Disconnected;
@@ -66,10 +59,10 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
spawn(async move {
while let Some(msg) = writer_recv_chan.next().await {
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
eprintln!("sending {:#?}", msg);
info!("sending packet {:#?}", msg);
}
if let Err(e) = writer.send(msg).await {
eprintln!("ERROR: {}", e);
error!("error sending packet {:?}", e);
break;
}
}
@@ -81,8 +74,7 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
Some(Err(err)) => bail!("bad version packet: {err:?}"),
None => bail!("no version was recieved"),
};
println!("Got version packet");
println!("{:#?}", version);
info!("got version packet {:#?}", version);
// Send version packet
let mut msg = msgs::Version::new();
@@ -126,28 +118,30 @@ pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
match packet {
Some(Ok(msg)) => {
if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) {
println!("receiving {:#?}", msg);
info!("receiving packet {:#?}", msg);
}
let res = accept_packet(msg, &mut audio, &mut decoder_map);
if let Err(err) = res {
err.log();
error!("error accepting packet {:?}", err)
}
},
Some(Err(err)) => Error::from(err).log(),
Some(Err(err)) => {
error!("error receiving packet {:?}", err)
},
None => break,
}
}
command = command_future => {
command_future = event_rx.next();
if let Some(command) = &command {
println!("commanding {:#?}", command);
info!("issuing command {:#?}", command);
}
match command {
Some(Command::Disconnect) => break,
Some(command) => {
let res = accept_command(command, &mut send_chan);
if let Err(err) = res {
err.log();
info!("error accepting command {:?}", err)
}
}
None => continue,
+2 -1
View File
@@ -1,4 +1,4 @@
use mumble_web2::app;
use mumble_web2_gui::{app, imp::init_logging};
pub fn main() {
#[cfg(feature = "desktop")]
@@ -7,5 +7,6 @@ pub fn main() {
.build()
.unwrap()
.enter();
init_logging();
dioxus::launch(app::app);
}
+4
View File
@@ -0,0 +1,4 @@
cert.pem
key.pem
bundle
config.toml
+2567
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "mumble-web2-proxy"
version = "0.1.0"
edition = "2021"
[dependencies]
color-eyre = "0.6.3"
serde = { version = "1.0.214", features = ["derive"] }
serde_json = "1.0.132"
tokio = { version = "1.37.0", features = ["full"] }
tokio-rustls = "^0.26"
toml = "0.8.19"
tracing = { version = "0.1.40", features = ["async-await"] }
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
mumble-web2-common = { workspace = true }
salvo = { version = "0.74.2", features = ["quinn", "eyre", "rustls", "serve-static", "logging"] }
once_cell = "1.20.2"
rustls = { version = "^0.23", features = ["aws_lc_rs"] }
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
cargo build --release
rm -rf mumble-web2
git clone https://git.ohea.xyz/mumble/mumble-web2
cd mumble-web2
echo "[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]" >server_hash.txt
dx build --release
cd ..
rm -rf bundle
mkdir bundle
cp target/release/mumble-webtransport-proxy bundle
cp -r mumble-web2/dist bundle/gui
+11
View File
@@ -0,0 +1,11 @@
https_listen_address = "127.0.0.1:4433"
http_listen_address = "127.0.0.1:8080"
cert_path = "./cert.pem"
key_path = "./key.pem"
mumble_server_url = "voip.ohea.xyz:64738"
gui_path = "../target/dx/mumble-web2-gui/release/web/public"
[gui]
force_proxy = true
proxy_url = "https://127.0.0.1:4433/proxy"
# cert_hash = [...]
+332
View File
@@ -0,0 +1,332 @@
use color_eyre::eyre::{anyhow, Context, Error, Result};
use mumble_web2_common::GuiConfig;
use once_cell::sync::OnceCell;
use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::logging::Logger;
use salvo::prelude::*;
use salvo::proto::quic::BidiStream;
use serde::Deserialize;
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tokio::fs;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::pin;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct};
use tokio_rustls::{rustls, TlsConnector};
use tracing::info;
use tracing::info_span;
use tracing::Instrument;
use tracing::{error, instrument};
use tracing_subscriber::filter::LevelFilter;
use tracing_subscriber::EnvFilter;
#[derive(Deserialize)]
struct Config {
https_listen_address: SocketAddr,
http_listen_address: Option<SocketAddr>,
cert_path: PathBuf,
key_path: PathBuf,
mumble_server_url: String,
mumble_server_address: Option<SocketAddr>,
gui_path: PathBuf,
gui: Mutex<GuiConfig>,
}
static CONFIG: OnceCell<Config> = OnceCell::new();
#[handler]
#[instrument]
async fn serve_gui_index_html(req: &Request, res: &mut Response) {
let config = CONFIG.get().unwrap();
// Load the HTML file
let path = config.gui_path.join("index.html");
let html = match fs::read_to_string(&path).await {
Ok(content) => content,
Err(err) => {
error!("could not load {}: {:?}", path.display(), err);
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
return;
}
};
// Insert the script tag with configuration
let modified_html = html.replace(
"</head>",
&format!(
"<script>window.config = {}</script>\n</head>",
serde_json::to_string(&config.gui).unwrap(),
),
);
res.render(Text::Html(modified_html));
}
#[handler]
async fn redirect_to_gui(res: &mut Response) {
res.render(Redirect::permanent("/gui"));
}
async fn init_config() -> Result<()> {
let mut config: Config = toml::from_str(
&fs::read_to_string("./config.toml")
.await
.context("reading config.toml (try making a copy of config.toml.example)")?,
)?;
let mumble_server_addr = config
.mumble_server_url
.to_socket_addrs()
.context(format!(
"parsing mumble_server_url={}",
config.mumble_server_url
))?
.next()
.ok_or(anyhow!(
"no socket addrs in mumble_server_url={}",
config.mumble_server_url
))?;
config.mumble_server_address = Some(mumble_server_addr);
CONFIG
.set(config)
.map_err(|_| anyhow!("config already initialized"))?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
init_logging();
init_config().await?;
let config = CONFIG.get().unwrap();
// Server routing
let router = Router::new()
.get(redirect_to_gui)
.push(Router::with_path("/proxy").goal(connect_proxy))
.push(Router::with_path("/gui").get(serve_gui_index_html))
.push(Router::with_path("/gui/<*+rest>").get(StaticDir::new(config.gui_path.clone())))
// right now dioxus assets don't properly handle base_url, so we are stuck with this
.push(
Router::with_path("/assets/<*+rest>")
.get(StaticDir::new(config.gui_path.join("assets"))),
)
.hoop(Logger::new());
// Read server certs
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?;
let cert = fs::read(&config.cert_path)
.await
.context(format!("reading cert {}", config.cert_path.display()))?;
let key = fs::read(&config.key_path)
.await
.context(format!("reading key {}", config.key_path.display()))?;
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
// Create http listeners
let http_listener = config.http_listen_address.map(TcpListener::new);
let https_listener =
TcpListener::new(config.https_listen_address).rustls(rustls_config.clone());
let http3_listener = QuinnListener::new(rustls_config, config.https_listen_address);
// Start server
match (http_listener, https_listener, http3_listener) {
(Some(a), b, c) => {
let accepter = a.join(b).join(c).bind().await;
Server::new(accepter).serve(router).await;
}
(None, b, c) => {
let accepter = b.join(c).bind().await;
Server::new(accepter).serve(router).await;
}
}
Ok(())
}
#[handler]
#[instrument]
async fn connect_proxy(req: &mut Request, res: &mut Response) {
info!("received proxy request");
let mumble_server_address = CONFIG.get().unwrap().mumble_server_address.unwrap();
let wt = match req.web_transport_mut().await {
Ok(wt) => wt,
Err(err) => {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!("error with webtransport: {err:?}"));
return;
}
};
info!("got webtransport for connection");
use salvo::webtransport::server::AcceptedBi;
let (id, bi) = match wt.accept_bi().await {
Ok(Some(AcceptedBi::BidiStream(id, bi))) => (id, bi),
Ok(Some(AcceptedBi::Request(req, _))) => {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!(
"expected webtransport stream but got request {req:?}"
));
return;
}
Ok(None) => {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!("no bidirectional connection requested"));
return;
}
Err(err) => {
res.status_code(StatusCode::INTERNAL_SERVER_ERROR);
res.render(format!("error with bidirectional connection: {err:?}"));
return;
}
};
/*
let id = wt.session_id();
let bi = match wt.open_bi(id).await {
Ok(bi) => bi,
Err(err) => {
res.status_code(StatusCode::BAD_REQUEST);
res.render(format!("could not open bidirectional stream: {err:?}"));
return;
}
};
*/
let (outgoing, incoming) = bi.split();
let res = tokio::spawn(async move {
if let Err(error) = connect_proxy_impl(mumble_server_address, incoming, outgoing).await {
error!("error connecting proxy {error:?}")
}
})
.await;
if let Err(err) = res {
error!("crash in connected proxy {err:?}");
}
}
#[instrument(skip(incoming, outgoing))]
async fn connect_proxy_impl(
mumble_server_address: SocketAddr,
incoming: impl AsyncRead + Send + Sync + 'static,
outgoing: impl AsyncWrite + Send + Sync + 'static,
) -> Result<()> {
info!("connecting to Mumble server...");
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let server_tcp = TcpStream::connect(mumble_server_address).await?;
let server_stream = connector
.connect("example.com".try_into()?, server_tcp)
.await?;
let (read_server, write_server) = tokio::io::split(server_stream);
info!("connected to Mumble server");
// Spawn tasks to handle transmitting data between the WebTransport client and Mumble TCP Server
let c2s = tokio::spawn(
pass_bytes_loop(incoming, write_server)
.instrument(info_span!("Handler", "Client to server")),
);
let s2c = tokio::spawn(
pass_bytes_loop(read_server, outgoing)
.instrument(info_span!("Handler", "Server to client")),
);
tokio::select! {
res = c2s => res??,
res = s2c => res??,
};
Ok(())
}
#[derive(Debug)]
struct NoCertificateVerification;
impl ServerCertVerifier for NoCertificateVerification {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA1,
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::ED448,
]
}
}
async fn pass_bytes_loop(
client_stream: impl AsyncRead + Sync + Send + 'static,
server_stream: impl AsyncWrite + Send + Sync + 'static,
) -> Result<()> {
let mut buffer = vec![0; 65536].into_boxed_slice();
pin!(client_stream);
pin!(server_stream);
loop {
let bytes_read = client_stream.read(&mut buffer).await?;
if bytes_read == 0 {
break Ok(());
}
server_stream.write_all(&buffer[..bytes_read]).await?;
server_stream.flush().await?;
}
}
fn init_logging() {
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::DEBUG.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}