move into gui folder for monorepo
This commit is contained in:
+632
@@ -0,0 +1,632 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use manganis::mg;
|
||||
use ordermap::OrderSet;
|
||||
use sir::{css, global_css};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{imp, CONFIG};
|
||||
|
||||
pub type ChannelId = u32;
|
||||
pub type UserId = u32;
|
||||
|
||||
pub enum ConnectionState {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Command {
|
||||
Connect {
|
||||
address: String,
|
||||
username: String,
|
||||
},
|
||||
SendChat {
|
||||
markdown: String,
|
||||
channels: Vec<ChannelId>,
|
||||
},
|
||||
SetMute {
|
||||
mute: bool,
|
||||
},
|
||||
SetDeaf {
|
||||
deaf: bool,
|
||||
},
|
||||
EnterChannel {
|
||||
channel: ChannelId,
|
||||
user: UserId,
|
||||
},
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
use Command::*;
|
||||
use ConnectionState::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ChannelState {
|
||||
pub name: String,
|
||||
pub children: OrderSet<ChannelId>,
|
||||
pub users: OrderSet<UserId>,
|
||||
pub parent: Option<ChannelId>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UserState {
|
||||
pub name: String,
|
||||
pub channel: ChannelId,
|
||||
pub deaf: bool,
|
||||
pub mute: bool,
|
||||
pub self_deaf: bool,
|
||||
pub self_mute: bool,
|
||||
}
|
||||
|
||||
impl UserState {
|
||||
pub fn icon(&self) -> UserIcon {
|
||||
match (self.mute || self.self_mute, self.deaf || self.self_deaf) {
|
||||
(false, false) => UserIcon::Normal,
|
||||
(true, false) => UserIcon::Muted,
|
||||
(_, true) => UserIcon::Deafened,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Chat {
|
||||
pub raw: String,
|
||||
pub dangerous_html: String,
|
||||
pub sender: Option<UserId>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ServerState {
|
||||
pub channels: HashMap<ChannelId, ChannelState>,
|
||||
pub users: HashMap<UserId, UserState>,
|
||||
pub chat: Vec<Chat>,
|
||||
pub session: Option<UserId>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn this_user(&self) -> Option<&UserState> {
|
||||
self.users.get(&self.session?)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub status: GlobalSignal<ConnectionState>,
|
||||
pub server: GlobalSignal<ServerState>,
|
||||
}
|
||||
|
||||
pub static STATE: State = State {
|
||||
status: Signal::global(|| Disconnected),
|
||||
server: Signal::global(|| Default::default()),
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UserIcon {
|
||||
Normal,
|
||||
Muted,
|
||||
Deafened,
|
||||
None,
|
||||
}
|
||||
|
||||
impl UserIcon {
|
||||
pub fn url(self) -> Option<&'static str> {
|
||||
// speaker from https://www.svgrepo.com/collection/ikono-bold-line-icons/
|
||||
// mic from https://www.svgrepo.com/collection/hashicorp-line-interface-icons/
|
||||
|
||||
use UserIcon::*;
|
||||
Some(match self {
|
||||
Normal => "/mic-svgrepo-com.svg",
|
||||
Muted => "/mic-off-svgrepo-com.svg",
|
||||
Deafened => "/speaker-muted-svgrepo-com.svg",
|
||||
None => return Option::None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn UserPill(name: String, icon: UserIcon) -> Element {
|
||||
let pill = css!(
|
||||
"
|
||||
border-radius: 100px;
|
||||
padding: 4px 8px;
|
||||
width: fit-content;
|
||||
|
||||
img {
|
||||
height: 1em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let color = match icon {
|
||||
UserIcon::Normal => "var(--accent-a)",
|
||||
UserIcon::Muted => "var(--accent-b)",
|
||||
UserIcon::Deafened => "var(--accent-c)",
|
||||
UserIcon::None => "var(--accent-a)",
|
||||
};
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "{pill}",
|
||||
style: "background-color: {color}",
|
||||
{ icon.url().map(|url| rsx!(img { src: url })) }
|
||||
"\u{00A0}{name}\u{00A0}"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn User(id: UserId) -> Element {
|
||||
let server = STATE.server.read();
|
||||
let state = server.users.get(&id)?;
|
||||
rsx!(UserPill {
|
||||
name: state.name.clone(),
|
||||
icon: state.icon(),
|
||||
})
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Channel(id: ChannelId) -> Element {
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
let server = STATE.server.read();
|
||||
let user = server.session?;
|
||||
let state = server.channels.get(&id)?;
|
||||
|
||||
let channel_details = css!(
|
||||
"
|
||||
flex: 0 0 100%;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
"
|
||||
);
|
||||
let channel_children = css!(
|
||||
"
|
||||
border-left: solid var(--line-color) var(--line-width);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-left: 5px;
|
||||
padding-left: 11px;
|
||||
padding-top: 4px; "
|
||||
);
|
||||
|
||||
rsx!(
|
||||
details {
|
||||
class: "{channel_details}",
|
||||
open: true,
|
||||
summary {
|
||||
span {
|
||||
role: "button",
|
||||
prevent_default: "onclick",
|
||||
ondoubleclick: move |evt| {
|
||||
evt.stop_propagation();
|
||||
net.send(EnterChannel { channel: id, user })
|
||||
},
|
||||
"{state.name}"
|
||||
}
|
||||
}
|
||||
if state.users.len() + state.children.len() > 0 {
|
||||
div {
|
||||
class: "{channel_children}",
|
||||
for id in state.users.iter() {
|
||||
User { id: *id }
|
||||
}
|
||||
for child in state.children.iter() {
|
||||
Channel { id: *child }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ChatView() -> Element {
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
let server = STATE.server.read();
|
||||
let mut draft = use_signal(|| "".to_string());
|
||||
|
||||
let chat_history = css!(
|
||||
"
|
||||
overflow-y: auto;
|
||||
flex: 1 0 0;
|
||||
"
|
||||
);
|
||||
|
||||
let chat_message = css!(
|
||||
"
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 16px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
"
|
||||
);
|
||||
|
||||
let chat_box = css!(
|
||||
"
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 16px;
|
||||
gap: 8px;
|
||||
border-top: solid var(--line-color) var(--line-width);
|
||||
|
||||
input {
|
||||
flex-grow: 1;
|
||||
padding: 8px;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let mut do_send = move || {
|
||||
if let Some(user) = STATE.server.read().this_user() {
|
||||
net.send(SendChat {
|
||||
markdown: draft.write().split_off(0),
|
||||
channels: vec![user.channel],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "{chat_history}",
|
||||
for chat in server.chat.iter() {
|
||||
div {
|
||||
class: "{chat_message}",
|
||||
if let Some(sender) = chat.sender.and_then(|u| server.users.get(&u)) {
|
||||
UserPill {
|
||||
name: sender.name.clone(),
|
||||
icon: UserIcon::None,
|
||||
}
|
||||
}
|
||||
span {
|
||||
dangerous_inner_html: "{chat.dangerous_html}",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "{chat_box}",
|
||||
input {
|
||||
placeholder: "say something",
|
||||
value: "{draft.read()}",
|
||||
oninput: move |evt| draft.set(evt.value().clone()),
|
||||
onkeypress: move |evt: Event<KeyboardData>| {
|
||||
if evt.code() == Code::Enter && evt.modifiers().is_empty() {
|
||||
do_send();
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
onclick: move |_| do_send(),
|
||||
"Send"
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ServerView() -> Element {
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
let server = STATE.server.read();
|
||||
let &UserState {
|
||||
deaf,
|
||||
self_deaf,
|
||||
mute,
|
||||
self_mute,
|
||||
..
|
||||
} = server.this_user()?;
|
||||
|
||||
let grid = css!(
|
||||
r#"
|
||||
display: grid;
|
||||
height: 100%;
|
||||
background-color: var(--bg-color);
|
||||
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"bar bar"
|
||||
"tree chat";
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
grid-template-rows: auto 1fr 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"bar"
|
||||
"tree"
|
||||
"chat";
|
||||
}
|
||||
"#
|
||||
);
|
||||
|
||||
let channel_box = css!(
|
||||
"
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
grid-area: tree;
|
||||
"
|
||||
);
|
||||
|
||||
let chat_box = css!(
|
||||
"
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: chat;
|
||||
border-left: solid var(--line-color) var(--line-width);
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
border-left:unset;
|
||||
border-top: solid var(--line-color) var(--line-width);
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let top_bar = css!(
|
||||
"
|
||||
padding: 16px;
|
||||
grid-area: bar;
|
||||
background-color: var(--login-bg-color);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
padding: 8px;
|
||||
|
||||
img {
|
||||
height: 1em;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
rsx!(
|
||||
div {
|
||||
class: "{grid}",
|
||||
div {
|
||||
class: "{top_bar}",
|
||||
button {
|
||||
onclick: move |_| net.send(Disconnect),
|
||||
"Disconnect"
|
||||
}
|
||||
button {
|
||||
role: "switch",
|
||||
aria_checked: mute || self_mute,
|
||||
disabled: mute,
|
||||
onclick: move |_| net.send(SetMute { mute: !self_mute }),
|
||||
match mute || self_mute {
|
||||
true => rsx!(img { src: "/mic-off-svgrepo-com.svg" }),
|
||||
false => rsx!(img { src: "/mic-svgrepo-com.svg" }),
|
||||
}
|
||||
"\u{00A0}Mute"
|
||||
}
|
||||
button {
|
||||
role: "switch",
|
||||
aria_checked: deaf || self_deaf,
|
||||
disabled: deaf,
|
||||
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
|
||||
match deaf || self_deaf {
|
||||
true => rsx!(img { src: "/speaker-muted-svgrepo-com.svg" }),
|
||||
false => rsx!(img { src: "/speaker-medium-svgrepo-com.svg" }),
|
||||
}
|
||||
"\u{00A0}Deafen"
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "{channel_box}",
|
||||
for (id, state) in server.channels.iter() {
|
||||
if state.parent.is_none() {
|
||||
Channel { id: *id }
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
class: "{chat_box}",
|
||||
ChatView {}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn LoginView() -> Element {
|
||||
let net: Coroutine<Command> = use_coroutine_handle();
|
||||
let default_address = CONFIG.proxy_url.as_deref().unwrap_or("");
|
||||
let mut address = use_signal(|| default_address.to_string());
|
||||
|
||||
let previous_username = imp::load_username();
|
||||
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
|
||||
|
||||
let error = css!(
|
||||
"
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
color: red;
|
||||
pre {
|
||||
color: black;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let login_box = css!(
|
||||
"
|
||||
max-width: 50vw;
|
||||
align-self: center;
|
||||
padding: 32px;
|
||||
border-radius: 16px;
|
||||
background-color: var(--login-bg-color);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
input,button {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #b3c6b4;
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
let bttn = css!(
|
||||
"
|
||||
font-weight: bold;
|
||||
font-size: large;
|
||||
"
|
||||
);
|
||||
|
||||
let do_connect = move |_| {
|
||||
//let _ = set_default_username(&username.read());
|
||||
let _ = imp::set_default_username(&username.read());
|
||||
net.send(Connect {
|
||||
address: address.read().clone(),
|
||||
username: username.read().clone(),
|
||||
})
|
||||
};
|
||||
let status = &STATE.status;
|
||||
let bottom = match &*status.read() {
|
||||
Disconnected => rsx! {
|
||||
button {
|
||||
class: "{bttn}",
|
||||
onclick: do_connect.clone(),
|
||||
"Connect"
|
||||
}
|
||||
},
|
||||
Connecting => rsx! {
|
||||
div {
|
||||
class: "{bttn}",
|
||||
"Connecting..."
|
||||
}
|
||||
},
|
||||
Failed(msg) => rsx!(
|
||||
button {
|
||||
class: "{bttn}",
|
||||
onclick: do_connect.clone(),
|
||||
"Reconnect"
|
||||
}
|
||||
div {
|
||||
class: "{error}",
|
||||
"Failed to connect:"
|
||||
pre {
|
||||
"{msg}"
|
||||
}
|
||||
}
|
||||
),
|
||||
Connected => unreachable!(),
|
||||
};
|
||||
rsx!(
|
||||
div {
|
||||
class: "{login_box}",
|
||||
h1 {
|
||||
"Mumble Web"
|
||||
}
|
||||
input {
|
||||
placeholder: "username",
|
||||
value: "{username.read()}",
|
||||
autofocus: "true",
|
||||
oninput: move |evt| username.set(evt.value().clone()),
|
||||
}
|
||||
input {
|
||||
placeholder: "server address",
|
||||
value: "{address.read()}",
|
||||
autofocus: "true",
|
||||
oninput: move |evt| address.set(evt.value().clone()),
|
||||
}
|
||||
{bottom}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub fn app() -> Element {
|
||||
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
|
||||
|
||||
global_css!(
|
||||
"
|
||||
:root {
|
||||
--txt-color: white;
|
||||
--bg-color: #372f3a;
|
||||
--login-bg-color: #5d7680;
|
||||
--primary-btn-color: #7bad9f;
|
||||
--accent-a: #8eb29a;
|
||||
--accent-b: #6a9395;
|
||||
--accent-c: #464459;
|
||||
--line-width: 2px;
|
||||
--line-color: white;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#main {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
background-color: var(--bg-color);
|
||||
overflow: auto;
|
||||
color: var(--txt-color);
|
||||
|
||||
font-family: sans-serif;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
button {
|
||||
font-weight: bold;
|
||||
font-size: medium;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--txt-color);
|
||||
background-color: var(--primary-btn-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
input:focus,input:focus-visible {
|
||||
border: none;
|
||||
outline: solid var(--line-width) var(--accent-a);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
a:link {
|
||||
color: var(--accent-a);
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--accent-b);
|
||||
}
|
||||
"
|
||||
);
|
||||
|
||||
rsx!(
|
||||
sir::AppStyle { }
|
||||
match *STATE.status.read() {
|
||||
Connected => rsx!(ServerView {}),
|
||||
_ => rsx!(LoginView {}),
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use crate::app::Command;
|
||||
use anyhow::Result;
|
||||
use dioxus::hooks::{UnboundedReceiver, UnboundedSender};
|
||||
use futures::io::{AsyncRead, AsyncWrite};
|
||||
use mumble_protocol::control::{ClientControlCodec, ControlPacket};
|
||||
use mumble_protocol::Serverbound;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::{fmt, io, sync::Arc};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::task::LocalSet;
|
||||
use tokio_rustls::rustls;
|
||||
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
|
||||
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use tokio_rustls::rustls::ClientConfig;
|
||||
use tokio_rustls::rustls::DigitallySignedStruct;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
|
||||
|
||||
pub use tokio::task::spawn;
|
||||
pub use tokio::time::sleep;
|
||||
|
||||
pub struct Error(anyhow::Error);
|
||||
|
||||
pub trait ImpRead: AsyncRead + Unpin + Send + 'static {}
|
||||
impl<T: AsyncRead + Unpin + Send + 'static> ImpRead for T {}
|
||||
|
||||
pub trait ImpWrite: AsyncWrite + Unpin + Send + 'static {}
|
||||
impl<T: AsyncWrite + Unpin + Send + 'static> ImpWrite for T {}
|
||||
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(value: anyhow::Error) -> Self {
|
||||
Error(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(value: io::Error) -> Self {
|
||||
Error(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn new(text: String) -> Self {
|
||||
Self(anyhow::Error::msg(text))
|
||||
}
|
||||
|
||||
pub fn log(&self) {
|
||||
eprintln!("{}", self.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Debug::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioSystem();
|
||||
|
||||
impl AudioSystem {
|
||||
pub fn new(sender: UnboundedSender<ControlPacket<Serverbound>>) -> Result<Self, Error> {
|
||||
// dbg!("todo");
|
||||
Ok(AudioSystem())
|
||||
}
|
||||
|
||||
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
|
||||
// dbg!("todo");
|
||||
Ok(AudioPlayer())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioPlayer();
|
||||
|
||||
impl AudioPlayer {
|
||||
pub fn play_opus(&mut self, payload: &[u8]) {
|
||||
// dbg!("todo");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NoCertificateVerification;
|
||||
|
||||
impl ServerCertVerifier for NoCertificateVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA1,
|
||||
rustls::SignatureScheme::ECDSA_SHA1_Legacy,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
rustls::SignatureScheme::ECDSA_NISTP521_SHA512,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::ED448,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
) -> Result<(), Error> {
|
||||
let config = ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
|
||||
let addr = format!("{}:{}", address, 64738)
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
let server_tcp = TcpStream::connect(addr).await?;
|
||||
let server_stream = connector
|
||||
//.connect("127.0.0.1".try_into()?, server_tcp)
|
||||
.connect(address.try_into().map_err(anyhow::Error::from)?, server_tcp)
|
||||
.await?;
|
||||
let (read_server, write_server) = tokio::io::split(server_stream);
|
||||
|
||||
let read_codec = ClientControlCodec::new();
|
||||
let write_codec = ClientControlCodec::new();
|
||||
|
||||
let mut reader = asynchronous_codec::FramedRead::new(read_server.compat(), read_codec);
|
||||
let mut writer = asynchronous_codec::FramedWrite::new(write_server.compat_write(), write_codec);
|
||||
|
||||
super::network_loop(username, event_rx, reader, writer).await
|
||||
}
|
||||
|
||||
pub fn set_default_username(username: &str) -> Option<()> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn load_username() -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
|
||||
pub fn load_config() -> Option<super::GuiConfig> {
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
use crate::app::Command;
|
||||
use crate::bail;
|
||||
use crate::CONFIG;
|
||||
use dioxus::prelude::*;
|
||||
use futures::AsyncRead;
|
||||
use futures::AsyncWrite;
|
||||
use futures_channel::mpsc::UnboundedSender;
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use manganis::mg;
|
||||
use mumble_protocol::control::ClientControlCodec;
|
||||
use mumble_protocol::control::ControlPacket;
|
||||
use mumble_protocol::voice::VoicePacket;
|
||||
use mumble_protocol::voice::VoicePacketPayload;
|
||||
use mumble_protocol::Serverbound;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::console;
|
||||
use web_sys::js_sys::Promise;
|
||||
use web_sys::js_sys::Reflect;
|
||||
use web_sys::js_sys::Uint8Array;
|
||||
use web_sys::window;
|
||||
use web_sys::AudioContext;
|
||||
use web_sys::AudioContextOptions;
|
||||
use web_sys::AudioData;
|
||||
use web_sys::AudioDecoder;
|
||||
use web_sys::AudioDecoderConfig;
|
||||
use web_sys::AudioDecoderInit;
|
||||
use web_sys::AudioEncoder;
|
||||
use web_sys::AudioEncoderConfig;
|
||||
use web_sys::AudioEncoderInit;
|
||||
use web_sys::AudioWorkletNode;
|
||||
use web_sys::EncodedAudioChunk;
|
||||
use web_sys::EncodedAudioChunkInit;
|
||||
use web_sys::EncodedAudioChunkType;
|
||||
use web_sys::MediaStream;
|
||||
use web_sys::MediaStreamConstraints;
|
||||
use web_sys::MediaStreamTrackGenerator;
|
||||
use web_sys::MediaStreamTrackGeneratorInit;
|
||||
use web_sys::MessageEvent;
|
||||
use web_sys::WebTransport;
|
||||
use web_sys::WebTransportBidirectionalStream;
|
||||
use web_sys::WebTransportOptions;
|
||||
use web_sys::WorkletOptions;
|
||||
|
||||
pub use wasm_bindgen_futures::spawn_local as spawn;
|
||||
|
||||
pub trait ImpRead: AsyncRead + Unpin + 'static {}
|
||||
impl<T: AsyncRead + Unpin + 'static> ImpRead for T {}
|
||||
|
||||
pub trait ImpWrite: AsyncWrite + Unpin + 'static {}
|
||||
impl<T: AsyncWrite + Unpin + 'static> ImpWrite for T {}
|
||||
|
||||
pub async fn sleep(d: Duration) {
|
||||
TimeoutFuture::new(d.as_millis() as u32).await
|
||||
}
|
||||
|
||||
pub struct Error(JsValue);
|
||||
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(value: anyhow::Error) -> Self {
|
||||
Error(JsError::new(&value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(value: io::Error) -> Self {
|
||||
Error(JsError::new(&value.to_string()).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsValue> for Error {
|
||||
fn from(value: JsValue) -> Self {
|
||||
Error(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsError> for Error {
|
||||
fn from(value: JsError) -> Self {
|
||||
Error(JsError::from(value).into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn new(text: String) -> Self {
|
||||
wasm_bindgen::JsError::new(&text).into()
|
||||
}
|
||||
|
||||
pub fn log(&self) {
|
||||
console::error_1(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let text: String = js_sys::Object::from(self.0.clone()).to_string().into();
|
||||
f.write_str(&text)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let text: String = js_sys::Object::from(self.0.clone()).to_string().into();
|
||||
f.write_str(&text)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioSystem(AudioContext);
|
||||
|
||||
impl AudioSystem {
|
||||
pub fn new(sender: UnboundedSender<ControlPacket<Serverbound>>) -> Result<Self, Error> {
|
||||
// Create MediaStreams to playback decoded audio
|
||||
// The audio context is used to reproduce audio.
|
||||
let audio_context = configure_audio_context();
|
||||
|
||||
let audio_context_worklet = audio_context.clone();
|
||||
spawn(async move {
|
||||
match create_encoder_worklet(&audio_context_worklet, sender).await {
|
||||
Ok(node) => console::log_2(&"Created audio worklet:".into(), &node),
|
||||
Err(err) => err.log(),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(AudioSystem(audio_context))
|
||||
}
|
||||
|
||||
pub fn create_player(&mut self) -> Result<AudioPlayer, Error> {
|
||||
let audio_context = &self.0;
|
||||
|
||||
let audio_stream_generator =
|
||||
MediaStreamTrackGenerator::new(&MediaStreamTrackGeneratorInit::new("audio"))?;
|
||||
|
||||
// Create MediaStream from MediaStreamTrackGenerator
|
||||
let js_tracks = web_sys::js_sys::Array::new();
|
||||
js_tracks.push(&audio_stream_generator);
|
||||
let media_stream = MediaStream::new_with_tracks(&js_tracks)?;
|
||||
|
||||
// Create MediaStreamAudioSourceNode
|
||||
let audio_source = audio_context.create_media_stream_source(&media_stream)?;
|
||||
// Connect output of audio_source to audio_context (browser audio)
|
||||
audio_source.connect_with_audio_node(&audio_context.destination())?;
|
||||
|
||||
// Create callback functions for AudioDecoder
|
||||
let error = Closure::wrap(Box::new(move |e: JsValue| {
|
||||
console::error_1(&e);
|
||||
}) as Box<dyn FnMut(JsValue)>);
|
||||
|
||||
// This knows what MediaStreamTrackGenerator to use as it closes around it
|
||||
let output = Closure::wrap(Box::new(move |audio_data: AudioData| {
|
||||
let writable = audio_stream_generator.writable();
|
||||
if writable.locked() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = writable.get_writer().map(|writer| {
|
||||
spawn(async move {
|
||||
if let Err(e) = JsFuture::from(writer.ready()).await {
|
||||
console::error_1(&format!("write chunk ready error {:?}", e).into());
|
||||
}
|
||||
if let Err(e) = JsFuture::from(writer.write_with_chunk(&audio_data)).await {
|
||||
console::error_1(&format!("write chunk error {:?}", e).into());
|
||||
};
|
||||
writer.release_lock();
|
||||
});
|
||||
}) {
|
||||
console::error_1(&e);
|
||||
}
|
||||
}) as Box<dyn FnMut(AudioData)>);
|
||||
|
||||
let audio_decoder = AudioDecoder::new(&AudioDecoderInit::new(
|
||||
error.as_ref().unchecked_ref(),
|
||||
output.as_ref().unchecked_ref(),
|
||||
))?;
|
||||
|
||||
audio_decoder.configure(&AudioDecoderConfig::new("opus", 1, 48000));
|
||||
console::log_1(&"Created Audio Decoder".into());
|
||||
|
||||
// This is required to prevent these from being deallocated
|
||||
error.forget();
|
||||
output.forget();
|
||||
|
||||
Ok(AudioPlayer(audio_decoder))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AudioPlayer(AudioDecoder);
|
||||
|
||||
impl AudioPlayer {
|
||||
pub fn play_opus(&mut self, payload: &[u8]) {
|
||||
let js_audio_payload = Uint8Array::from(payload);
|
||||
let _ = self.0.decode(
|
||||
&EncodedAudioChunk::new(&EncodedAudioChunkInit::new(
|
||||
&js_audio_payload.into(),
|
||||
0.0,
|
||||
EncodedAudioChunkType::Key,
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Borrowed from
|
||||
// https://github.com/security-union/videocall-rs/blob/main/videocall-client/src/decode/config.rs#L6
|
||||
fn configure_audio_context() -> AudioContext {
|
||||
let mut audio_context_options = AudioContextOptions::new();
|
||||
audio_context_options.sample_rate(48000 as f32);
|
||||
let audio_context = AudioContext::new_with_context_options(&audio_context_options).unwrap();
|
||||
audio_context
|
||||
}
|
||||
|
||||
trait PromiseExt {
|
||||
fn into_future(self) -> JsFuture;
|
||||
}
|
||||
|
||||
impl PromiseExt for Promise {
|
||||
fn into_future(self) -> JsFuture {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_encoder_worklet(
|
||||
audio_context: &AudioContext,
|
||||
packets: UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
|
||||
) -> Result<AudioWorkletNode, Error> {
|
||||
let stream = window()
|
||||
.unwrap()
|
||||
.navigator()
|
||||
.media_devices()?
|
||||
.get_user_media_with_constraints(MediaStreamConstraints::new().audio(&JsValue::TRUE))?
|
||||
.into_future()
|
||||
.await?
|
||||
.dyn_into()
|
||||
.map_err(|e| JsError::new(&format!("not a stream: {e:?}")))?;
|
||||
|
||||
let options = WorkletOptions::new();
|
||||
Reflect::set(
|
||||
&options,
|
||||
&"processorOptions".into(),
|
||||
&wasm_bindgen::module(),
|
||||
)?;
|
||||
|
||||
let module = "/rust_mic_worklet.js";
|
||||
console::log_1(&format!("Loading mic worklet from {module:?}").into());
|
||||
audio_context
|
||||
.audio_worklet()?
|
||||
.add_module_with_options(module, &options)?
|
||||
.into_future()
|
||||
.await?;
|
||||
|
||||
let source = audio_context.create_media_stream_source(&stream)?;
|
||||
let worklet_node = AudioWorkletNode::new(audio_context, "rust_mic_worklet")?;
|
||||
|
||||
let error: Closure<dyn FnMut(JsValue)> = Closure::new(|e| console::error_1(&e));
|
||||
|
||||
let download_buffer = std::cell::RefCell::new(Vec::new());
|
||||
|
||||
// This knows what MediaStreamTrackGenerator to use as it closes around it
|
||||
let mut sequence_num = 0;
|
||||
let output: Closure<dyn FnMut(EncodedAudioChunk)> =
|
||||
Closure::new(move |audio_data: EncodedAudioChunk| {
|
||||
let mut array = vec![0u8; audio_data.byte_length() as usize];
|
||||
audio_data.copy_to_with_u8_slice(&mut array);
|
||||
|
||||
download_buffer.borrow_mut().push(array.clone());
|
||||
if download_buffer.borrow().len() > 200 {
|
||||
//download_data(download_buffer.borrow().to_vec(), "download_buffer.opus");
|
||||
//download_data(
|
||||
// ass::encode(download_buffer.borrow().to_vec(), 960, 0),
|
||||
// "download_buffer.opus",
|
||||
//);
|
||||
download_buffer.borrow_mut().clear();
|
||||
}
|
||||
|
||||
let _ =
|
||||
packets.unbounded_send(ControlPacket::UDPTunnel(Box::new(VoicePacket::Audio {
|
||||
_dst: std::marker::PhantomData,
|
||||
target: 0,
|
||||
session_id: (),
|
||||
seq_num: sequence_num,
|
||||
payload: VoicePacketPayload::Opus(array.into(), false),
|
||||
position_info: None,
|
||||
})));
|
||||
sequence_num = sequence_num.wrapping_add(2);
|
||||
});
|
||||
|
||||
let audio_encoder = AudioEncoder::new(&AudioEncoderInit::new(
|
||||
error.as_ref().unchecked_ref(),
|
||||
output.as_ref().unchecked_ref(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// This is required to prevent these from being deallocated
|
||||
error.forget();
|
||||
output.forget();
|
||||
let encoder_config = AudioEncoderConfig::new("opus");
|
||||
encoder_config.set_number_of_channels(1);
|
||||
encoder_config.set_sample_rate(48000);
|
||||
encoder_config.set_bitrate(72_000.0);
|
||||
|
||||
audio_encoder.configure(&encoder_config);
|
||||
console::log_1(&"Created Audio Encoder".into());
|
||||
|
||||
let download_buffer = std::cell::RefCell::new(Vec::new());
|
||||
|
||||
let onmessage: Closure<dyn FnMut(MessageEvent)> = Closure::new(move |event: MessageEvent| {
|
||||
match AudioData::new(event.data().unchecked_ref()) {
|
||||
Ok(data) => {
|
||||
let x = web_sys::AudioDataCopyToOptions::new(0);
|
||||
x.set_format(web_sys::AudioSampleFormat::F32);
|
||||
let mut sub_buffer = vec![0; data.allocation_size(&x).unwrap() as usize];
|
||||
data.copy_to_with_u8_slice(&mut sub_buffer, &x);
|
||||
download_buffer.borrow_mut().append(&mut sub_buffer);
|
||||
if download_buffer.borrow().len() > 48000 * 10 * 4 {
|
||||
//pub fn download_data(data: Vec<u8>, filename: &str) -> Result<(), JsValue> {
|
||||
//download_data(download_buffer.borrow().to_vec(), "download_buffer.pcm32");
|
||||
download_buffer.borrow_mut().clear();
|
||||
}
|
||||
|
||||
audio_encoder.encode(&data);
|
||||
}
|
||||
Err(err) => {
|
||||
console::error_1(&err);
|
||||
console::debug_1(&event);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reflect::set(
|
||||
&Reflect::get(&worklet_node, &"port".into())?,
|
||||
&"onmessage".into(),
|
||||
onmessage.as_ref(),
|
||||
)?;
|
||||
onmessage.forget();
|
||||
|
||||
source.connect_with_audio_node(&worklet_node)?;
|
||||
worklet_node.connect_with_audio_node(&audio_context.destination())?;
|
||||
|
||||
Ok(worklet_node)
|
||||
}
|
||||
|
||||
pub async fn network_connect(
|
||||
address: String,
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
) -> Result<(), Error> {
|
||||
console::log_1(&"Rust via WASM!".into());
|
||||
|
||||
let object = web_sys::js_sys::Object::new();
|
||||
|
||||
Reflect::set(
|
||||
&object,
|
||||
&JsValue::from_str("algorithm"),
|
||||
&JsValue::from_str("sha-256"),
|
||||
)?;
|
||||
|
||||
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)?;
|
||||
}
|
||||
|
||||
let array = web_sys::js_sys::Array::new();
|
||||
array.push(&object);
|
||||
|
||||
console::log_1(&object.clone().into());
|
||||
console::log_1(&"Created option object!".into());
|
||||
|
||||
let mut options = WebTransportOptions::new();
|
||||
options.server_certificate_hashes(&array);
|
||||
|
||||
console::log_1(&"Created WebTransportOptions!".into());
|
||||
|
||||
let transport = WebTransport::new_with_options(&address, &options)?;
|
||||
console::log_1(&"Created WebTransport connection object.".into());
|
||||
console::log_1(&transport.clone().into());
|
||||
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(transport.ready()).await {
|
||||
bail!("could not connect to transport: {e:?}");
|
||||
}
|
||||
|
||||
console::log_1(&"Transport is ready.".into());
|
||||
|
||||
let stream: WebTransportBidirectionalStream =
|
||||
wasm_bindgen_futures::JsFuture::from(transport.create_bidirectional_stream())
|
||||
.await?
|
||||
.into();
|
||||
|
||||
let wasm_stream_readable = wasm_streams::ReadableStream::from_raw(stream.readable().into());
|
||||
let wasm_stream_writable = wasm_streams::WritableStream::from_raw(stream.writable().into());
|
||||
|
||||
let read_codec = ClientControlCodec::new();
|
||||
let write_codec = ClientControlCodec::new();
|
||||
|
||||
let reader =
|
||||
asynchronous_codec::FramedRead::new(wasm_stream_readable.into_async_read(), read_codec);
|
||||
let writer =
|
||||
asynchronous_codec::FramedWrite::new(wasm_stream_writable.into_async_write(), write_codec);
|
||||
|
||||
super::network_loop(username, event_rx, reader, writer).await
|
||||
}
|
||||
|
||||
pub fn set_default_username(username: &str) -> Option<()> {
|
||||
web_sys::window()?
|
||||
.local_storage()
|
||||
.ok()??
|
||||
.set_item("username", username)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn load_username() -> Option<String> {
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.local_storage()
|
||||
.ok()??
|
||||
.get_item("username")
|
||||
.ok()?
|
||||
}
|
||||
|
||||
pub fn load_config() -> Option<super::GuiConfig> {
|
||||
serde_wasm_bindgen::from_value(Reflect::get(window()?.as_ref(), &"config".into()).ok()?).ok()
|
||||
}
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
use app::Chat;
|
||||
use app::Command;
|
||||
use app::ConnectionState;
|
||||
use app::STATE;
|
||||
use asynchronous_codec::FramedRead;
|
||||
use asynchronous_codec::FramedWrite;
|
||||
use dioxus::prelude::*;
|
||||
use futures::select;
|
||||
use futures::FutureExt as _;
|
||||
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 once_cell::sync::Lazy;
|
||||
use serde::Deserialize;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod app;
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
#[path = "imp/web.rs"]
|
||||
pub mod imp;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
#[path = "imp/desktop.rs"]
|
||||
pub mod imp;
|
||||
|
||||
#[derive(Clone, Deserialize, Default)]
|
||||
pub struct GuiConfig {
|
||||
pub proxy_url: Option<String>,
|
||||
pub cert_hash: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub static CONFIG: Lazy<GuiConfig> = Lazy::new(|| imp::load_config().unwrap_or_default());
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! bail {
|
||||
($($x:tt)*) => {
|
||||
return Err(Error::new(format!($($x)*)))
|
||||
};
|
||||
}
|
||||
|
||||
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")
|
||||
};
|
||||
|
||||
*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();
|
||||
*STATE.status.write() = ConnectionState::Failed(error.to_string());
|
||||
} else {
|
||||
*STATE.status.write() = ConnectionState::Disconnected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn network_loop<R: imp::ImpRead, W: imp::ImpWrite>(
|
||||
username: String,
|
||||
event_rx: &mut UnboundedReceiver<Command>,
|
||||
mut reader: FramedRead<R, ControlCodec<Serverbound, Clientbound>>,
|
||||
mut writer: FramedWrite<W, ControlCodec<Serverbound, Clientbound>>,
|
||||
) -> Result<(), Error> {
|
||||
let (mut send_chan, mut writer_recv_chan) = futures_channel::mpsc::unbounded();
|
||||
spawn(async move {
|
||||
while let Some(msg) = writer_recv_chan.next().await {
|
||||
if !matches!(msg, ControlPacket::Ping(_) | ControlPacket::UDPTunnel(_)) {
|
||||
eprintln!("sending {:#?}", msg);
|
||||
}
|
||||
if let Err(e) = writer.send(msg).await {
|
||||
eprintln!("ERROR: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get version packet
|
||||
let version = match reader.next().await {
|
||||
Some(Ok(v)) => v,
|
||||
Some(Err(err)) => bail!("bad version packet: {err:?}"),
|
||||
None => bail!("no version was recieved"),
|
||||
};
|
||||
println!("Got version packet");
|
||||
println!("{:#?}", version);
|
||||
|
||||
// Send version packet
|
||||
let mut msg = msgs::Version::new();
|
||||
msg.set_version(0x000010204);
|
||||
msg.set_release(format!("{} {}", "mumbleweb2", "6.9.0"));
|
||||
//msg.set_os("Chrome".to_string());
|
||||
send_chan.send(msg.into()).await.unwrap();
|
||||
|
||||
// Send authenticate packet
|
||||
let mut msg = msgs::Authenticate::new();
|
||||
msg.set_username(username);
|
||||
msg.set_opus(true);
|
||||
send_chan.send(msg.into()).await.unwrap();
|
||||
|
||||
// Spawn worker to send pings
|
||||
{
|
||||
let mut send_chan = send_chan.clone();
|
||||
spawn(async move {
|
||||
loop {
|
||||
if let Err(_) = send_chan.send(msgs::Ping::new().into()).await {
|
||||
break;
|
||||
}
|
||||
|
||||
imp::sleep(Duration::from_millis(3000)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut audio = imp::AudioSystem::new(send_chan.clone())?;
|
||||
|
||||
// Create map of session_id -> AudioDecoder
|
||||
let mut decoder_map = HashMap::new();
|
||||
|
||||
let mut reader_future = reader.next().fuse();
|
||||
let mut command_future = event_rx.next();
|
||||
|
||||
loop {
|
||||
select! {
|
||||
packet = reader_future => {
|
||||
reader_future = reader.next().fuse();
|
||||
match packet {
|
||||
Some(Ok(msg)) => {
|
||||
if !matches!(msg, ControlPacket::UDPTunnel(_) | ControlPacket::Ping(_)) {
|
||||
println!("receiving {:#?}", msg);
|
||||
}
|
||||
let res = accept_packet(msg, &mut audio, &mut decoder_map);
|
||||
if let Err(err) = res {
|
||||
err.log();
|
||||
}
|
||||
},
|
||||
Some(Err(err)) => Error::from(err).log(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
command = command_future => {
|
||||
command_future = event_rx.next();
|
||||
if let Some(command) = &command {
|
||||
println!("commanding {:#?}", command);
|
||||
}
|
||||
match command {
|
||||
Some(Command::Disconnect) => break,
|
||||
Some(command) => {
|
||||
let res = accept_command(command, &mut send_chan);
|
||||
if let Err(err) = res {
|
||||
err.log();
|
||||
}
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = send_chan.close();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn accept_command(
|
||||
command: Command,
|
||||
send_chan: &mut UnboundedSender<ControlPacket<mumble_protocol::Serverbound>>,
|
||||
) -> Result<(), Error> {
|
||||
use Command::*;
|
||||
let Some(session) = STATE.server.read().session else {
|
||||
bail!("no session id")
|
||||
};
|
||||
|
||||
match command {
|
||||
SendChat { markdown, channels } => {
|
||||
use markdown::*;
|
||||
let blocks = tokenize(&markdown);
|
||||
let html_text = match blocks.as_slice() {
|
||||
[Block::Paragraph(par)] => match par.as_slice() {
|
||||
[Span::Text(text)] => text.to_string(),
|
||||
_ => to_html(&markdown)
|
||||
.trim()
|
||||
.strip_prefix("<p>")
|
||||
.unwrap()
|
||||
.strip_suffix("</p>")
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
},
|
||||
_ => to_html(&markdown).trim().to_string(),
|
||||
};
|
||||
|
||||
{
|
||||
let mut server = STATE.server.write();
|
||||
let Some(me) = server.session else {
|
||||
bail!("not signed in with a session id")
|
||||
};
|
||||
server.chat.push(Chat {
|
||||
raw: markdown,
|
||||
dangerous_html: html_text.clone(),
|
||||
sender: Some(me),
|
||||
})
|
||||
}
|
||||
|
||||
let mut u = msgs::TextMessage::new();
|
||||
u.set_message(html_text.to_string());
|
||||
u.set_channel_id(channels);
|
||||
let _ = send_chan.unbounded_send(u.into());
|
||||
}
|
||||
SetMute { mute } => {
|
||||
let mut u = msgs::UserState::new();
|
||||
u.set_session(session);
|
||||
u.set_self_mute(mute);
|
||||
let _ = send_chan.unbounded_send(u.into());
|
||||
}
|
||||
SetDeaf { deaf } => {
|
||||
let mut u = msgs::UserState::new();
|
||||
u.set_session(session);
|
||||
u.set_self_deaf(deaf);
|
||||
let _ = send_chan.unbounded_send(u.into());
|
||||
}
|
||||
EnterChannel { channel, user } => {
|
||||
let mut u = msgs::UserState::new();
|
||||
u.set_session(user);
|
||||
u.set_channel_id(channel);
|
||||
let _ = send_chan.unbounded_send(u.into());
|
||||
}
|
||||
Connect { .. } | Disconnect => (),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn accept_packet(
|
||||
msg: ControlPacket<mumble_protocol::Clientbound>,
|
||||
audio_context: &mut imp::AudioSystem,
|
||||
player_map: &mut HashMap<u32, imp::AudioPlayer>,
|
||||
) -> Result<(), Error> {
|
||||
match msg {
|
||||
ControlPacket::UDPTunnel(u) => {
|
||||
match *u.clone() {
|
||||
mumble_protocol::voice::VoicePacket::Audio {
|
||||
_dst,
|
||||
target,
|
||||
session_id,
|
||||
seq_num,
|
||||
payload,
|
||||
position_info,
|
||||
} => {
|
||||
// Get or create audio decoder for this user
|
||||
let audio_player = match player_map.entry(session_id) {
|
||||
Entry::Occupied(occupied_entry) => occupied_entry.into_mut(),
|
||||
Entry::Vacant(vacant_entry) => {
|
||||
vacant_entry.insert(audio_context.create_player()?)
|
||||
}
|
||||
};
|
||||
// This will over time (as users join and leave) leak
|
||||
// AudioDecoders, MediaStreamTrackGenerators, MediaStreams, and MediaStreamAudioSourceNodes.
|
||||
// A better way to handle this would be to delete and create all the audio
|
||||
// infra on channel join and update it as new users join the channel, dropping
|
||||
// any audio packets that come in the meantime.
|
||||
if let VoicePacketPayload::Opus(audio_payload, end_bit) = payload {
|
||||
audio_player.play_opus(audio_payload.as_ref());
|
||||
//console::log_1(&"Oueued audio chunk for decoding".into());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
unreachable!("TCP tunnels UDP packets should not contain pings");
|
||||
// I think?
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlPacket::ChannelState(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let id = u.get_channel_id();
|
||||
|
||||
let state = server.channels.entry(id).or_default();
|
||||
let new_parent = if u.has_parent() {
|
||||
if let Some(parent) = state.parent.and_then(|p| server.channels.get_mut(&p)) {
|
||||
parent.children.remove(&id);
|
||||
}
|
||||
|
||||
let parent_id = u.get_parent();
|
||||
let parent = server.channels.entry(parent_id).or_default();
|
||||
if u.has_position() && u.get_position() as usize <= parent.children.len() {
|
||||
// TODO: what if positions are received out of order? we need to sort afterwards?
|
||||
parent.children.insert_before(u.get_position() as usize, id);
|
||||
} else {
|
||||
parent.children.insert(id);
|
||||
}
|
||||
Some(parent_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state = server.channels.entry(id).or_default();
|
||||
state.parent = new_parent;
|
||||
if u.has_name() {
|
||||
state.name = u.get_name().to_string();
|
||||
}
|
||||
}
|
||||
ControlPacket::ChannelRemove(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let id = u.get_channel_id();
|
||||
if let Some(channel) = server.channels.remove(&id) {
|
||||
if let Some(parent) = channel.parent.and_then(|p| server.channels.get_mut(&p)) {
|
||||
parent.children.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlPacket::UserState(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let server = &mut *server;
|
||||
let id = u.get_session();
|
||||
|
||||
let state_entry = server.users.entry(id);
|
||||
let new = matches!(state_entry, std::collections::hash_map::Entry::Vacant(_));
|
||||
let state = state_entry.or_default();
|
||||
// the server might now send a channel_id if the user is in channel=0
|
||||
if u.has_channel_id() || new {
|
||||
if let Some(parent) = server.channels.get_mut(&state.channel) {
|
||||
parent.users.remove(&id);
|
||||
}
|
||||
|
||||
let channel_id = u.get_channel_id();
|
||||
server
|
||||
.channels
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
.users
|
||||
.insert(id);
|
||||
state.channel = channel_id;
|
||||
}
|
||||
|
||||
if u.has_name() {
|
||||
state.name = u.get_name().to_string();
|
||||
}
|
||||
if u.has_mute() {
|
||||
state.mute = u.get_mute();
|
||||
}
|
||||
if u.has_deaf() {
|
||||
state.deaf = u.get_deaf();
|
||||
}
|
||||
if u.has_self_mute() {
|
||||
state.self_mute = u.get_self_mute();
|
||||
}
|
||||
if u.has_self_deaf() {
|
||||
state.self_deaf = u.get_self_deaf();
|
||||
}
|
||||
}
|
||||
ControlPacket::UserRemove(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
let id = u.get_session();
|
||||
if let Some(state) = server.users.remove(&id) {
|
||||
if let Some(parent) = server.channels.get_mut(&state.channel) {
|
||||
parent.users.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlPacket::TextMessage(u) => {
|
||||
let mut server = STATE.server.write();
|
||||
if u.has_message() {
|
||||
let text = u.get_message().to_string();
|
||||
server.chat.push(Chat {
|
||||
sender: if u.has_actor() {
|
||||
Some(u.get_actor())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
dangerous_html: html_purifier::purifier(&text, Default::default()),
|
||||
raw: text,
|
||||
});
|
||||
}
|
||||
}
|
||||
ControlPacket::ServerSync(u) => {
|
||||
*STATE.status.write() = ConnectionState::Connected;
|
||||
let mut server = STATE.server.write();
|
||||
if u.has_welcome_text() {
|
||||
let text = u.get_welcome_text().to_string();
|
||||
server.chat.push(Chat {
|
||||
sender: None,
|
||||
dangerous_html: html_purifier::purifier(&text, Default::default()),
|
||||
raw: text,
|
||||
});
|
||||
}
|
||||
if u.has_session() {
|
||||
server.session = Some(u.get_session());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use mumble_web2_gui::app;
|
||||
|
||||
pub fn main() {
|
||||
#[cfg(feature = "desktop")]
|
||||
let _guard = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.enter();
|
||||
dioxus::launch(app::app);
|
||||
}
|
||||
Reference in New Issue
Block a user