use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use crate::element::ElementDecl;
use crate::protocol::{ClientMsg, ServerMsg};
const HTML_TEMPLATE: &str = include_str!("frontend.html");
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub fn spawn_http(
shutdown: Arc<AtomicBool>,
port: u16,
bind_addr: &str,
title: &str,
favicon: Option<Vec<u8>>,
) -> thread::JoinHandle<()> {
let bind = bind_addr.to_string();
let html = HTML_TEMPLATE.replace("__WGUI_TITLE__", &html_escape(title));
thread::Builder::new()
.name("wgui-http".into())
.spawn(move || run_http(shutdown, port, &bind, html, favicon))
.expect("failed to spawn wgui HTTP thread")
}
pub fn spawn_ws(
ws_rx: mpsc::Receiver<Vec<ServerMsg>>,
edit_tx: mpsc::Sender<(String, crate::element::Value)>,
port: u16,
bind_addr: &str,
) -> thread::JoinHandle<()> {
let bind = bind_addr.to_string();
thread::Builder::new()
.name("wgui-ws".into())
.spawn(move || run_ws(ws_rx, edit_tx, port, &bind))
.expect("failed to spawn wgui WS thread")
}
fn run_http(shutdown: Arc<AtomicBool>, port: u16, bind_addr: &str, html: String, favicon: Option<Vec<u8>>) {
let server = tiny_http::Server::http(format!("{bind_addr}:{port}"))
.unwrap_or_else(|e| panic!("wgui: failed to bind HTTP on port {port}: {e}"));
log::info!("wgui: serving UI at http://{bind_addr}:{port}");
loop {
if shutdown.load(Ordering::Acquire) {
break;
}
match server.recv_timeout(Duration::from_millis(200)) {
Ok(Some(request)) => {
match request.url() {
"/" | "/index.html" => {
let response = tiny_http::Response::from_string(&html)
.with_header(
"Content-Type: text/html; charset=utf-8"
.parse::<tiny_http::Header>()
.unwrap(),
);
let _ = request.respond(response);
}
"/favicon.png" => {
if let Some(ref icon) = favicon {
let response = tiny_http::Response::from_data(icon.clone())
.with_header(
"Content-Type: image/png"
.parse::<tiny_http::Header>()
.unwrap(),
);
let _ = request.respond(response);
} else {
let _ = request.respond(
tiny_http::Response::from_string("404 Not Found")
.with_status_code(404),
);
}
}
_ => {
let _ = request.respond(
tiny_http::Response::from_string("404 Not Found")
.with_status_code(404),
);
}
}
}
Ok(None) => {} Err(e) => {
log::error!("wgui HTTP error: {e}");
break;
}
}
}
}
fn run_ws(
ws_rx: mpsc::Receiver<Vec<ServerMsg>>,
edit_tx: mpsc::Sender<(String, crate::element::Value)>,
port: u16,
bind_addr: &str,
) {
let listener = TcpListener::bind(format!("{bind_addr}:{port}"))
.unwrap_or_else(|e| panic!("wgui: failed to bind WS on port {port}: {e}"));
listener
.set_nonblocking(true)
.expect("failed to set WS listener non-blocking");
log::info!("wgui: WebSocket listening on ws://{bind_addr}:{port}");
let mut mirror: Vec<ElementDecl> = Vec::new();
loop {
match listener.accept() {
Ok((stream, _addr)) => {
log::info!("wgui: new WebSocket client connected");
handle_ws_client(&ws_rx, &edit_tx, stream, &mut mirror);
log::info!("wgui: WebSocket client disconnected");
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
while let Ok(msgs) = ws_rx.try_recv() {
apply_messages_to_mirror(&mut mirror, &msgs);
}
thread::sleep(Duration::from_millis(50));
}
Err(e) => {
log::error!("wgui WS accept error: {e}");
thread::sleep(Duration::from_millis(100));
}
}
}
}
fn handle_ws_client(
ws_rx: &mpsc::Receiver<Vec<ServerMsg>>,
edit_tx: &mpsc::Sender<(String, crate::element::Value)>,
stream: TcpStream,
mirror: &mut Vec<ElementDecl>,
) {
stream
.set_nonblocking(false)
.expect("failed to set stream blocking");
stream
.set_read_timeout(Some(Duration::from_millis(16)))
.ok();
let mut ws = match tungstenite::accept(stream) {
Ok(ws) => ws,
Err(e) => {
log::error!("wgui: WS handshake failed: {e}");
return;
}
};
let snapshot = ServerMsg::Snapshot {
elements: mirror.clone(),
};
if let Ok(json) = serde_json::to_string(&snapshot) {
if ws.send(tungstenite::Message::Text(json.into())).is_err() {
return;
}
}
loop {
loop {
match ws_rx.try_recv() {
Ok(msgs) => {
apply_messages_to_mirror(mirror, &msgs);
for msg in &msgs {
if let Ok(json) = serde_json::to_string(&msg) {
if ws
.send(tungstenite::Message::Text(json.into()))
.is_err()
{
return;
}
}
}
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
let _ = ws.close(None);
return;
}
}
}
match ws.read() {
Ok(tungstenite::Message::Text(text)) => {
if let Ok(client_msg) = serde_json::from_str::<ClientMsg>(&text) {
match client_msg {
ClientMsg::Set { id, value } => {
let _ = edit_tx.send((id, value));
}
}
}
}
Ok(tungstenite::Message::Close(_)) => {
let _ = ws.close(None);
break;
}
Ok(_) => {} Err(tungstenite::Error::Io(ref e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
}
Err(tungstenite::Error::ConnectionClosed) => break,
Err(tungstenite::Error::AlreadyClosed) => break,
Err(e) => {
log::warn!("wgui: WS read error: {e}");
break;
}
}
}
}
fn apply_messages_to_mirror(mirror: &mut Vec<ElementDecl>, msgs: &[ServerMsg]) {
for msg in msgs {
match msg {
ServerMsg::Snapshot { elements } => {
*mirror = elements.clone();
}
ServerMsg::Add { element } => {
mirror.push(element.clone());
}
ServerMsg::Update { id, value, label, meta } => {
if let Some(elem) = mirror.iter_mut().find(|e| &e.id == id) {
elem.value = value.clone();
if let Some(l) = label {
elem.label = l.clone();
}
if let Some(m) = meta {
elem.meta = m.clone();
}
}
}
ServerMsg::Remove { id } => {
mirror.retain(|e| &e.id != id);
}
}
}
}
pub fn find_port_pair(start: u16, bind_addr: &str) -> (u16, u16) {
for base in (start..start + 100).step_by(2) {
let http_ok = TcpListener::bind(format!("{bind_addr}:{base}")).is_ok();
let ws_ok = TcpListener::bind(format!("{bind_addr}:{}", base + 1)).is_ok();
if http_ok && ws_ok {
return (base, base + 1);
}
}
panic!("wgui: could not find available port pair starting from {start}");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::element::{ElementDecl, ElementKind, ElementMeta, Value};
use std::sync::Arc;
fn make_decl(id: &str, value: Value) -> ElementDecl {
ElementDecl {
id: id.to_string(),
kind: ElementKind::Label,
label: id.to_string(),
value,
meta: ElementMeta::default(),
window: Arc::from("test"),
}
}
#[test]
fn mirror_add() {
let mut mirror = vec![];
let msgs = vec![ServerMsg::Add {
element: make_decl("a", Value::Bool(true)),
}];
apply_messages_to_mirror(&mut mirror, &msgs);
assert_eq!(mirror.len(), 1);
assert_eq!(mirror[0].id, "a");
}
#[test]
fn mirror_update() {
let mut mirror = vec![make_decl("a", Value::Bool(true))];
let msgs = vec![ServerMsg::Update {
id: "a".to_string(),
value: Value::Bool(false),
label: None,
meta: None,
}];
apply_messages_to_mirror(&mut mirror, &msgs);
assert_eq!(mirror[0].value, Value::Bool(false));
}
#[test]
fn mirror_remove() {
let mut mirror = vec![make_decl("a", Value::Bool(true))];
let msgs = vec![ServerMsg::Remove {
id: "a".to_string(),
}];
apply_messages_to_mirror(&mut mirror, &msgs);
assert!(mirror.is_empty());
}
#[test]
fn mirror_snapshot() {
let mut mirror = vec![make_decl("a", Value::Bool(true))];
let msgs = vec![ServerMsg::Snapshot {
elements: vec![make_decl("b", Value::Bool(false))],
}];
apply_messages_to_mirror(&mut mirror, &msgs);
assert_eq!(mirror.len(), 1);
assert_eq!(mirror[0].id, "b");
}
#[test]
fn html_escape_special_chars() {
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("a&b"), "a&b");
assert_eq!(html_escape("\"hi\""), ""hi"");
assert_eq!(html_escape("plain"), "plain");
}
}